From c830f05efa0d69e71e2b1820f6198e906ced5ae4 Mon Sep 17 00:00:00 2001 From: Frederik Kischewski Date: Mon, 4 May 2026 19:35:39 +0200 Subject: [PATCH 1/9] feat: add offline mode with Room DB for usage without registration Users can now use the app without creating an account by clicking "App offline nutzen" on the login screen. Recipes, ingredients and materials are downloaded once from Firebase on first offline start, then stored locally in Room DB. - Add Room 2.8.4 with KSP for Android, Desktop and iOS - Annotate existing model classes as Room @Entity (no duplication) - Add TypeConverters using existing kotlinx.serialization infrastructure - Implement RoomRepository with all 41 EventRepository methods - Add DelegatingRepository/DelegatingLoginAndRegister for runtime mode switching - Add AppModeHolder with StateFlow for reactive mode changes - Add platform-specific DatabaseBuilder (expect/actual) and AppModePreferences - Add SeedDataService for one-time recipe/ingredient download from Firebase - Add "App offline nutzen" button to login screen --- build.gradle.kts | 2 + composeApp/build.gradle.kts | 15 + .../schemas/data.local.AppDatabase/1.json | 577 ++++++++++++++++++ .../kotlin/data/AppModePreferences.android.kt | 27 + .../data/local/DatabaseBuilder.android.kt | 13 + composeApp/src/commonMain/kotlin/App.kt | 26 +- .../src/commonMain/kotlin/data/AppMode.kt | 7 + .../commonMain/kotlin/data/AppModeHolder.kt | 16 + .../kotlin/data/AppModePreferences.kt | 8 + .../kotlin/data/DelegatingRepository.kt | 69 +++ .../kotlin/data/FireBaseRepository.kt | 3 +- .../kotlin/data/local/AppDatabase.kt | 35 ++ .../kotlin/data/local/Converters.kt | 78 +++ .../kotlin/data/local/DatabaseBuilder.kt | 5 + .../kotlin/data/local/RoomRepository.kt | 391 ++++++++++++ .../kotlin/data/local/dao/BaseDao.kt | 21 + .../kotlin/data/local/dao/EventDao.kt | 18 + .../kotlin/data/local/dao/IngredientDao.kt | 14 + .../kotlin/data/local/dao/MaterialDao.kt | 14 + .../kotlin/data/local/dao/MealDao.kt | 17 + .../data/local/dao/MultiDayShoppingListDao.kt | 14 + .../kotlin/data/local/dao/ParticipantDao.kt | 21 + .../data/local/dao/ParticipantTimeDao.kt | 20 + .../kotlin/data/local/dao/RecipeDao.kt | 20 + .../kotlin/model/DailyShoppingList.kt | 11 +- .../src/commonMain/kotlin/model/Event.kt | 5 +- .../src/commonMain/kotlin/model/Ingredient.kt | 5 +- .../src/commonMain/kotlin/model/Material.kt | 5 +- .../src/commonMain/kotlin/model/Meal.kt | 8 +- .../commonMain/kotlin/model/Participant.kt | 5 +- .../kotlin/model/ParticipantTime.kt | 17 +- .../kotlin/model/RecepieSelection.kt | 7 +- .../src/commonMain/kotlin/model/Recipe.kt | 5 +- .../kotlin/model/ShoppingIngredient.kt | 7 +- .../commonMain/kotlin/modules/DataModules.kt | 16 +- .../kotlin/modules/ServiceModules.kt | 6 +- .../kotlin/services/SeedDataService.kt | 52 ++ .../login/DelegatingLoginAndRegister.kt | 25 + .../services/login/OfflineLoginAndRegister.kt | 24 + .../src/commonMain/kotlin/view/login/Login.kt | 32 + .../view/navigation/RootNavController.kt | 23 +- .../kotlin/data/AppModePreferences.desktop.kt | 24 + .../data/local/DatabaseBuilder.desktop.kt | 14 + .../kotlin/data/AppModePreferences.ios.kt | 24 + .../kotlin/data/local/DatabaseBuilder.ios.kt | 12 + gradle/libs.versions.toml | 10 + 46 files changed, 1727 insertions(+), 41 deletions(-) create mode 100644 composeApp/schemas/data.local.AppDatabase/1.json create mode 100644 composeApp/src/androidMain/kotlin/data/AppModePreferences.android.kt create mode 100644 composeApp/src/androidMain/kotlin/data/local/DatabaseBuilder.android.kt create mode 100644 composeApp/src/commonMain/kotlin/data/AppMode.kt create mode 100644 composeApp/src/commonMain/kotlin/data/AppModeHolder.kt create mode 100644 composeApp/src/commonMain/kotlin/data/AppModePreferences.kt create mode 100644 composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/Converters.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/DatabaseBuilder.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/BaseDao.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/EventDao.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/IngredientDao.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/MaterialDao.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/MealDao.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/MultiDayShoppingListDao.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/ParticipantDao.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/ParticipantTimeDao.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt create mode 100644 composeApp/src/commonMain/kotlin/services/SeedDataService.kt create mode 100644 composeApp/src/commonMain/kotlin/services/login/DelegatingLoginAndRegister.kt create mode 100644 composeApp/src/commonMain/kotlin/services/login/OfflineLoginAndRegister.kt create mode 100644 composeApp/src/desktopMain/kotlin/data/AppModePreferences.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/data/local/DatabaseBuilder.desktop.kt create mode 100644 composeApp/src/iosMain/kotlin/data/AppModePreferences.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/data/local/DatabaseBuilder.ios.kt diff --git a/build.gradle.kts b/build.gradle.kts index d5d631ed..70724765 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,4 +9,6 @@ plugins { alias(libs.plugins.google.services) apply false alias(libs.plugins.kotlinxSerialization) apply false alias(libs.plugins.kotlinCocoapods) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.room) apply false } \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 7bb597b8..cf975b1b 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -14,6 +14,12 @@ plugins { alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.google.services) alias(libs.plugins.buildkonfig) + alias(libs.plugins.ksp) + alias(libs.plugins.room) +} + +room { + schemaDirectory("$projectDir/schemas") } composeCompiler { @@ -133,6 +139,10 @@ kotlin { //implementation(libs.gitlive.firebase.crashlytics) implementation(libs.kermit) + + // Room + implementation(libs.room.runtime) + implementation(libs.sqlite.bundled) } desktopMain.dependencies { implementation(compose.desktop.currentOs) @@ -189,6 +199,11 @@ android { } dependencies { testImplementation(libs.junit.jupiter) + add("kspAndroid", libs.room.compiler) + add("kspDesktop", libs.room.compiler) + add("kspIosX64", libs.room.compiler) + add("kspIosArm64", libs.room.compiler) + add("kspIosSimulatorArm64", libs.room.compiler) } compose.desktop { diff --git a/composeApp/schemas/data.local.AppDatabase/1.json b/composeApp/schemas/data.local.AppDatabase/1.json new file mode 100644 index 00000000..f29f136e --- /dev/null +++ b/composeApp/schemas/data.local.AppDatabase/1.json @@ -0,0 +1,577 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "4527db3fc1481c0cd69957e92d2c2f8f", + "entities": [ + { + "tableName": "events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` TEXT NOT NULL, `uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `eventType` TEXT NOT NULL, `kitchenSchedule` TEXT, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kitchenSchedule", + "columnName": "kitchenSchedule", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "participants", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `allergies` TEXT NOT NULL, `intolerances` TEXT NOT NULL, `group` TEXT NOT NULL, `selectedGroup` TEXT NOT NULL, `birthdate` INTEGER, `eatingHabit` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "allergies", + "columnName": "allergies", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "intolerances", + "columnName": "intolerances", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedGroup", + "columnName": "selectedGroup", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthdate", + "columnName": "birthdate", + "affinity": "INTEGER" + }, + { + "fieldPath": "eatingHabit", + "columnName": "eatingHabit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "recipes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `cookingInstructions` TEXT NOT NULL, `notes` TEXT NOT NULL, `description` TEXT NOT NULL, `dietaryHabit` TEXT NOT NULL, `shoppingIngredients` TEXT NOT NULL, `materials` TEXT NOT NULL, `name` TEXT NOT NULL, `pageInCookbook` INTEGER NOT NULL, `price` TEXT NOT NULL, `season` TEXT NOT NULL, `foodIntolerances` TEXT NOT NULL, `source` TEXT NOT NULL, `time` TEXT NOT NULL, `skillLevel` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookingInstructions", + "columnName": "cookingInstructions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dietaryHabit", + "columnName": "dietaryHabit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shoppingIngredients", + "columnName": "shoppingIngredients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "materials", + "columnName": "materials", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageInCookbook", + "columnName": "pageInCookbook", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "season", + "columnName": "season", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "foodIntolerances", + "columnName": "foodIntolerances", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "skillLevel", + "columnName": "skillLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "ingredients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `amountHaferl` REAL, `unitHaferl` TEXT, `amountTablespoon` REAL, `unitTablespoon` TEXT, `amountTeaspoon` REAL, `unitTeaspoon` TEXT, `amountWeight` REAL, `unitWeight` TEXT, `category` TEXT NOT NULL, `expirationDateInDays` INTEGER, `intolerances` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountHaferl", + "columnName": "amountHaferl", + "affinity": "REAL" + }, + { + "fieldPath": "unitHaferl", + "columnName": "unitHaferl", + "affinity": "TEXT" + }, + { + "fieldPath": "amountTablespoon", + "columnName": "amountTablespoon", + "affinity": "REAL" + }, + { + "fieldPath": "unitTablespoon", + "columnName": "unitTablespoon", + "affinity": "TEXT" + }, + { + "fieldPath": "amountTeaspoon", + "columnName": "amountTeaspoon", + "affinity": "REAL" + }, + { + "fieldPath": "unitTeaspoon", + "columnName": "unitTeaspoon", + "affinity": "TEXT" + }, + { + "fieldPath": "amountWeight", + "columnName": "amountWeight", + "affinity": "REAL" + }, + { + "fieldPath": "unitWeight", + "columnName": "unitWeight", + "affinity": "TEXT" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationDateInDays", + "columnName": "expirationDateInDays", + "affinity": "INTEGER" + }, + { + "fieldPath": "intolerances", + "columnName": "intolerances", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "meals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `day` INTEGER NOT NULL, `mealType` TEXT NOT NULL, `recipeSelections` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mealType", + "columnName": "mealType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipeSelections", + "columnName": "recipeSelections", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "shopping_ingredients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `ingredientRef` TEXT NOT NULL, `nameEnteredByUser` TEXT NOT NULL, `amount` REAL NOT NULL, `unit` TEXT NOT NULL, `title` TEXT, `shoppingDone` INTEGER NOT NULL, `note` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ingredientRef", + "columnName": "ingredientRef", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameEnteredByUser", + "columnName": "nameEnteredByUser", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "shoppingDone", + "columnName": "shoppingDone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "recipe_selections", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `eaterIds` TEXT NOT NULL, `recipeRef` TEXT NOT NULL, `selectedRecipeName` TEXT NOT NULL, `guestCount` INTEGER NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eaterIds", + "columnName": "eaterIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipeRef", + "columnName": "recipeRef", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedRecipeName", + "columnName": "selectedRecipeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "guestCount", + "columnName": "guestCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "participant_times", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `participantRef` TEXT NOT NULL, `cookingGroup` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantRef", + "columnName": "participantRef", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookingGroup", + "columnName": "cookingGroup", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "materials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `amount` INTEGER NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "multi_day_shopping_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `dailyLists` TEXT NOT NULL, PRIMARY KEY(`eventId`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dailyLists", + "columnName": "dailyLists", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4527db3fc1481c0cd69957e92d2c2f8f')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/data/AppModePreferences.android.kt b/composeApp/src/androidMain/kotlin/data/AppModePreferences.android.kt new file mode 100644 index 00000000..afedccd7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/data/AppModePreferences.android.kt @@ -0,0 +1,27 @@ +package data + +import android.content.Context +import org.futterbock.app.MainActivity + +actual class AppModePreferences actual constructor() { + private val prefs by lazy { + MainActivity.appContext.getSharedPreferences("futterbock_prefs", Context.MODE_PRIVATE) + } + + actual fun getAppMode(): AppMode { + val name = prefs.getString("app_mode", null) ?: return AppMode.ONLINE + return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.ONLINE } + } + + actual fun setAppMode(mode: AppMode) { + prefs.edit().putString("app_mode", mode.name).apply() + } + + actual fun isSeedDataDownloaded(): Boolean { + return prefs.getBoolean("seed_data_downloaded", false) + } + + actual fun setSeedDataDownloaded(downloaded: Boolean) { + prefs.edit().putBoolean("seed_data_downloaded", downloaded).apply() + } +} diff --git a/composeApp/src/androidMain/kotlin/data/local/DatabaseBuilder.android.kt b/composeApp/src/androidMain/kotlin/data/local/DatabaseBuilder.android.kt new file mode 100644 index 00000000..42780e8c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/data/local/DatabaseBuilder.android.kt @@ -0,0 +1,13 @@ +package data.local + +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import org.futterbock.app.MainActivity + +actual fun getDatabaseBuilder(): RoomDatabase.Builder { + val context = MainActivity.appContext + val dbFile = context.getDatabasePath("futterbock.db") + return Room.databaseBuilder(context, dbFile.absolutePath) + .setDriver(BundledSQLiteDriver()) +} diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index 947bebe4..40181010 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -1,10 +1,14 @@ -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import data.AppMode +import data.AppModeHolder +import data.AppModePreferences import modules.dataModules import modules.serviceModules import modules.viewModelModules import org.koin.compose.KoinApplication import org.koin.compose.koinInject +import org.koin.dsl.module import services.pdfService.PdfServiceImpl import view.navigation.RootNavController import services.pdfService.PdfServiceModule @@ -13,24 +17,26 @@ import view.theme.AppTheme @Composable fun App(pdfService: PdfServiceImpl) { + val prefs = remember { AppModePreferences() } + val appModeHolder = remember { AppModeHolder(prefs.getAppMode()) } + + val appModeModule = remember { + module { + single { appModeHolder } + single { prefs } + } + } + KoinApplication(application = { modules( - dataModules, viewModelModules, serviceModules + appModeModule, serviceModules, dataModules, viewModelModules ) }) { val pdfServiceModule: PdfServiceModule = koinInject() - pdfServiceModule.setPdfService(pdfService) AppTheme { RootNavController() - //Navigator(NewParicipantScreen(null)) {navigator -> SlideTransition(navigator) } } } } - -@Composable -fun AppContent(pdfService: PdfServiceImpl) { - - -} diff --git a/composeApp/src/commonMain/kotlin/data/AppMode.kt b/composeApp/src/commonMain/kotlin/data/AppMode.kt new file mode 100644 index 00000000..9b3bbcf3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/AppMode.kt @@ -0,0 +1,7 @@ +package data + +enum class AppMode { + ONLINE, + OFFLINE_ONLY, + OFFLINE_FIRST +} diff --git a/composeApp/src/commonMain/kotlin/data/AppModeHolder.kt b/composeApp/src/commonMain/kotlin/data/AppModeHolder.kt new file mode 100644 index 00000000..e141f00a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/AppModeHolder.kt @@ -0,0 +1,16 @@ +package data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class AppModeHolder(initialMode: AppMode) { + private val _mode = MutableStateFlow(initialMode) + val mode: StateFlow = _mode.asStateFlow() + + fun switchMode(newMode: AppMode) { + val prefs = AppModePreferences() + prefs.setAppMode(newMode) + _mode.value = newMode + } +} diff --git a/composeApp/src/commonMain/kotlin/data/AppModePreferences.kt b/composeApp/src/commonMain/kotlin/data/AppModePreferences.kt new file mode 100644 index 00000000..ea4fdc56 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/AppModePreferences.kt @@ -0,0 +1,8 @@ +package data + +expect class AppModePreferences() { + fun getAppMode(): AppMode + fun setAppMode(mode: AppMode) + fun isSeedDataDownloaded(): Boolean + fun setSeedDataDownloaded(downloaded: Boolean) +} diff --git a/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt b/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt new file mode 100644 index 00000000..c88dc65e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt @@ -0,0 +1,69 @@ +package data + +import data.local.AppDatabase +import data.local.RoomRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import model.* +import services.login.LoginAndRegister +import services.login.OfflineLoginAndRegister + +class DelegatingRepository( + private val appModeHolder: AppModeHolder, + private val db: AppDatabase, + private val firebaseLoginAndRegister: LoginAndRegister +) : EventRepository { + + private val offlineLogin = OfflineLoginAndRegister() + + private val firebaseRepository by lazy { FireBaseRepository(firebaseLoginAndRegister) } + private val roomRepository by lazy { RoomRepository(db, offlineLogin) } + + private val active: EventRepository + get() = when (appModeHolder.mode.value) { + AppMode.OFFLINE_ONLY -> roomRepository + else -> firebaseRepository + } + + override suspend fun deleteEvent(eventId: String) = active.deleteEvent(eventId) + override suspend fun getEventById(eventId: String) = active.getEventById(eventId) + override suspend fun createNewEvent() = active.createNewEvent() + override suspend fun saveExistingEvent(event: Event) = active.saveExistingEvent(event) + override suspend fun getEventList(group: String) = active.getEventList(group) + override suspend fun getNumberOfParticipants(eventId: String) = active.getNumberOfParticipants(eventId) + override suspend fun getParticipantsOfEvent(eventId: String, withParticipant: Boolean) = active.getParticipantsOfEvent(eventId, withParticipant) + override suspend fun getAllParticipantsOfStamm() = active.getAllParticipantsOfStamm() + override suspend fun deleteParticipantOfEvent(eventId: String, participantId: String) = active.deleteParticipantOfEvent(eventId, participantId) + override suspend fun addParticipantToEvent(newParticipant: Participant, event: Event) = active.addParticipantToEvent(newParticipant, event) + override suspend fun createNewParticipant(participant: Participant) = active.createNewParticipant(participant) + override suspend fun updateParticipant(participant: Participant) = active.updateParticipant(participant) + override suspend fun deleteParticipant(participantId: String) = active.deleteParticipant(participantId) + override suspend fun getParticipantById(participantId: String) = active.getParticipantById(participantId) + override suspend fun findParticipantByName(firstName: String, lastName: String) = active.findParticipantByName(firstName, lastName) + override suspend fun getAllRecipes() = active.getAllRecipes() + override suspend fun getUserCreatedRecipes() = active.getUserCreatedRecipes() + override suspend fun getMealById(eventId: String, mealId: String) = active.getMealById(eventId, mealId) + override suspend fun getRecipeById(recipeId: String) = active.getRecipeById(recipeId) + override suspend fun createRecipe(recipe: Recipe) = active.createRecipe(recipe) + override suspend fun updateRecipe(recipe: Recipe) = active.updateRecipe(recipe) + override suspend fun deleteRecipe(recipeId: String) = active.deleteRecipe(recipeId) + override suspend fun getAllMealsOfEvent(eventId: String) = active.getAllMealsOfEvent(eventId) + override suspend fun createNewMeal(eventId: String, day: Instant) = active.createNewMeal(eventId, day) + override suspend fun deleteMeal(eventId: String, mealId: String) = active.deleteMeal(eventId, mealId) + override suspend fun updateMeal(eventId: String, meal: Meal) = active.updateMeal(eventId, meal) + override suspend fun updateParticipantTime(eventId: String, participant: ParticipantTime) = active.updateParticipantTime(eventId, participant) + override suspend fun getIngredientById(ingredientId: String) = active.getIngredientById(ingredientId) + override suspend fun getMealsWithRecipeAndIngredients(eventId: String) = active.getMealsWithRecipeAndIngredients(eventId) + override suspend fun getMultiDayShoppingList(eventId: String) = active.getMultiDayShoppingList(eventId) + override suspend fun saveMultiDayShoppingList(eventId: String, multiDayShoppingList: MultiDayShoppingList) = active.saveMultiDayShoppingList(eventId, multiDayShoppingList) + override suspend fun getDailyShoppingList(eventId: String, date: LocalDate) = active.getDailyShoppingList(eventId, date) + override suspend fun saveDailyShoppingList(eventId: String, date: LocalDate, dailyShoppingList: DailyShoppingList) = active.saveDailyShoppingList(eventId, date, dailyShoppingList) + override suspend fun updateShoppingIngredientStatus(eventId: String, date: LocalDate, ingredientId: String, completed: Boolean) = active.updateShoppingIngredientStatus(eventId, date, ingredientId, completed) + override suspend fun deleteShoppingListForDate(eventId: String, date: LocalDate) = active.deleteShoppingListForDate(eventId, date) + override suspend fun getAllIngredients() = active.getAllIngredients() + override suspend fun saveMaterialList(eventId: String, materialList: List) = active.saveMaterialList(eventId, materialList) + override suspend fun getMaterialListOfEvent(eventId: String) = active.getMaterialListOfEvent(eventId) + override suspend fun deleteMaterialById(eventId: String, materialId: String) = active.deleteMaterialById(eventId, materialId) + override suspend fun getAllMaterials() = active.getAllMaterials() +} diff --git a/composeApp/src/commonMain/kotlin/data/FireBaseRepository.kt b/composeApp/src/commonMain/kotlin/data/FireBaseRepository.kt index 54b05315..9762ab06 100644 --- a/composeApp/src/commonMain/kotlin/data/FireBaseRepository.kt +++ b/composeApp/src/commonMain/kotlin/data/FireBaseRepository.kt @@ -613,13 +613,12 @@ class FireBaseRepository(private val loginAndRegister: LoginAndRegister) : Event ): ParticipantTime { val participantId = generateRandomStringId() val participant = ParticipantTime( - participant = newParticipant, from = event.from, to = event.to, uid = participantId, participantRef = newParticipant.uid, cookingGroup = newParticipant.selectedGroup.takeIf { it.isNotBlank() } ?: "" - ) + ).also { it.participant = newParticipant } firestore.collection(EVENTS) .document(event.uid) .collection(PARTICIPANT_SCHEDULE) diff --git a/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt b/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt new file mode 100644 index 00000000..9846dfd8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt @@ -0,0 +1,35 @@ +package data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import data.local.dao.* +import model.* + +@Database( + entities = [ + Event::class, + Participant::class, + Recipe::class, + Ingredient::class, + Meal::class, + ShoppingIngredient::class, + RecipeSelection::class, + ParticipantTime::class, + Material::class, + MultiDayShoppingList::class, + ], + version = 1, + exportSchema = true +) +@TypeConverters(Converters::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun eventDao(): EventDao + abstract fun participantDao(): ParticipantDao + abstract fun recipeDao(): RecipeDao + abstract fun ingredientDao(): IngredientDao + abstract fun mealDao(): MealDao + abstract fun participantTimeDao(): ParticipantTimeDao + abstract fun materialDao(): MaterialDao + abstract fun multiDayShoppingListDao(): MultiDayShoppingListDao +} diff --git a/composeApp/src/commonMain/kotlin/data/local/Converters.kt b/composeApp/src/commonMain/kotlin/data/local/Converters.kt new file mode 100644 index 00000000..99ee8dc0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/Converters.kt @@ -0,0 +1,78 @@ +package data.local + +import androidx.room.TypeConverter +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import model.* + +class Converters { + private val json = Json { ignoreUnknownKeys = true } + + // Instant <-> Long + @TypeConverter fun fromInstant(value: Instant): Long = value.toEpochMilliseconds() + @TypeConverter fun toInstant(value: Long): Instant = Instant.fromEpochMilliseconds(value) + + @TypeConverter fun fromInstantNullable(value: Instant?): Long? = value?.toEpochMilliseconds() + @TypeConverter fun toInstantNullable(value: Long?): Instant? = value?.let { Instant.fromEpochMilliseconds(it) } + + // LocalDate <-> String + @TypeConverter fun fromLocalDate(value: LocalDate): String = value.toString() + @TypeConverter fun toLocalDate(value: String): LocalDate = LocalDate.parse(value) + + // List + @TypeConverter fun fromStringList(value: List): String = json.encodeToString(value) + @TypeConverter fun toStringList(value: String): List = json.decodeFromString(value) + + // MutableSet + @TypeConverter fun fromStringSet(value: MutableSet): String = json.encodeToString(value.toList()) + @TypeConverter fun toStringSet(value: String): MutableSet = json.decodeFromString>(value).toMutableSet() + + // List + @TypeConverter fun fromFoodIntoleranceList(value: List): String = json.encodeToString(value.map { it.name }) + @TypeConverter fun toFoodIntoleranceList(value: String): List = json.decodeFromString>(value).map { FoodIntolerance.valueOf(it) } + + // List + @TypeConverter fun fromSeasonList(value: List): String = json.encodeToString(value.map { it.name }) + @TypeConverter fun toSeasonList(value: String): List = json.decodeFromString>(value).map { Season.valueOf(it) } + + // List + @TypeConverter fun fromRecipeTypeList(value: List): String = json.encodeToString(value.map { it.name }) + @TypeConverter fun toRecipeTypeList(value: String): List = json.decodeFromString>(value).map { RecipeType.valueOf(it) } + + // Enums + @TypeConverter fun fromEatingHabit(value: EatingHabit): String = value.name + @TypeConverter fun toEatingHabit(value: String): EatingHabit = EatingHabit.valueOf(value) + + @TypeConverter fun fromEventType(value: EventType): String = value.name + @TypeConverter fun toEventType(value: String): EventType = EventType.valueOf(value) + + @TypeConverter fun fromMealType(value: MealType): String = value.name + @TypeConverter fun toMealType(value: String): MealType = MealType.valueOf(value) + + @TypeConverter fun fromIngredientUnit(value: IngredientUnit): String = value.name + @TypeConverter fun toIngredientUnit(value: String): IngredientUnit = IngredientUnit.valueOf(value) + + @TypeConverter fun fromIngredientUnitNullable(value: IngredientUnit?): String? = value?.name + @TypeConverter fun toIngredientUnitNullable(value: String?): IngredientUnit? = value?.let { IngredientUnit.valueOf(it) } + + @TypeConverter fun fromSource(value: Source): String = value.name + @TypeConverter fun toSource(value: String): Source = Source.valueOf(value) + + @TypeConverter fun fromRange(value: Range): String = value.name + @TypeConverter fun toRange(value: String): Range = Range.valueOf(value) + + @TypeConverter fun fromTimeRange(value: TimeRange): String = value.name + @TypeConverter fun toTimeRange(value: String): TimeRange = TimeRange.valueOf(value) + + // Complex nested objects as JSON using kotlinx.serialization + @TypeConverter fun fromShoppingIngredientList(value: List): String = json.encodeToString(value) + @TypeConverter fun toShoppingIngredientList(value: String): List = json.decodeFromString(value) + + @TypeConverter fun fromRecipeSelectionList(value: List): String = json.encodeToString(value) + @TypeConverter fun toRecipeSelectionList(value: String): List = json.decodeFromString(value) + + @TypeConverter fun fromDailyListsMap(value: Map): String = json.encodeToString(value) + @TypeConverter fun toDailyListsMap(value: String): Map = json.decodeFromString(value) +} diff --git a/composeApp/src/commonMain/kotlin/data/local/DatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/data/local/DatabaseBuilder.kt new file mode 100644 index 00000000..4585504d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/DatabaseBuilder.kt @@ -0,0 +1,5 @@ +package data.local + +import androidx.room.RoomDatabase + +expect fun getDatabaseBuilder(): RoomDatabase.Builder diff --git a/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt b/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt new file mode 100644 index 00000000..6fb80a3d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt @@ -0,0 +1,391 @@ +package data.local + +import co.touchlab.kermit.Logger +import data.EventRepository +import kotlin.time.Clock +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import model.* +import services.login.LoginAndRegister +import view.shared.HelperFunctions.Companion.generateRandomStringId +import kotlin.time.Duration.Companion.days + +class RoomRepository( + private val db: AppDatabase, + private val loginAndRegister: LoginAndRegister +) : EventRepository { + + // --- Event --- + + override suspend fun deleteEvent(eventId: String) { + db.eventDao().deleteById(eventId) + } + + override suspend fun getEventById(eventId: String): Event? { + return db.eventDao().getById(eventId) + } + + override suspend fun createNewEvent(): Event { + val eventId = generateRandomStringId() + val userGroup = loginAndRegister.getCustomUserGroup() + val event = Event(userGroup).apply { + uid = eventId + name = "Neues Lager" + from = Clock.System.now() + to = Clock.System.now().plus(2.days) + } + db.eventDao().insert(event) + return event + } + + override suspend fun saveExistingEvent(event: Event) { + db.eventDao().insert(event) + } + + override suspend fun getEventList(group: String): Flow> { + return db.eventDao().getByGroup(group) + } + + // --- Participants --- + + override suspend fun getNumberOfParticipants(eventId: String): Int { + return db.participantTimeDao().getCountByEventId(eventId) + } + + override suspend fun getParticipantsOfEvent( + eventId: String, + withParticipant: Boolean + ): List { + val participantTimes = db.participantTimeDao().getByEventId(eventId) + + if (!withParticipant || participantTimes.isEmpty()) { + return participantTimes + } + + val validParticipantTimes = mutableListOf() + val orphanedIds = mutableListOf() + + participantTimes.forEach { pt -> + val participant = db.participantDao().getById(pt.participantRef) + if (participant != null) { + pt.participant = participant + validParticipantTimes.add(pt) + } else { + orphanedIds.add(pt.participantRef) + } + } + + orphanedIds.forEach { id -> + db.participantTimeDao().deleteByEventAndParticipant(eventId, id) + } + + return validParticipantTimes + } + + override suspend fun getAllParticipantsOfStamm(): Flow> { + val group = loginAndRegister.getCustomUserGroup() + return db.participantDao().getByGroup(group) + } + + override suspend fun deleteParticipantOfEvent(eventId: String, participantId: String) { + db.participantTimeDao().deleteByEventAndParticipant(eventId, participantId) + } + + override suspend fun addParticipantToEvent( + newParticipant: Participant, + event: Event + ): ParticipantTime { + val participantTimeId = generateRandomStringId() + val participantTime = ParticipantTime( + uid = participantTimeId, + from = event.from, + to = event.to, + participantRef = newParticipant.uid, + cookingGroup = newParticipant.selectedGroup.takeIf { it.isNotBlank() } ?: "" + ).also { + it.participant = newParticipant + it.eventId = event.uid + } + db.participantTimeDao().insert(participantTime) + return participantTime + } + + override suspend fun createNewParticipant(participant: Participant): Participant? { + try { + val existing = findParticipantByName(participant.firstName, participant.lastName) + if (existing != null) { + Logger.w("Participant with name '${participant.firstName} ${participant.lastName}' already exists.") + return null + } + + val participantId = generateRandomStringId() + participant.uid = participantId + participant.group = loginAndRegister.getCustomUserGroup() + db.participantDao().insert(participant) + return participant + } catch (e: Exception) { + Logger.e("Error creating participant: ${participant.firstName} ${participant.lastName}", e) + return null + } + } + + override suspend fun updateParticipant(participant: Participant) { + participant.group = loginAndRegister.getCustomUserGroup() + db.participantDao().insert(participant) + } + + override suspend fun deleteParticipant(participantId: String) { + val userGroup = loginAndRegister.getCustomUserGroup() + val events = db.eventDao().getByGroup(userGroup) + var eventsList: List = emptyList() + events.collect { eventsList = it; return@collect } + + eventsList.forEach { event -> + val meals = db.mealDao().getByEventId(event.uid) + meals.forEach { meal -> + var mealUpdated = false + meal.recipeSelections.forEach { selection -> + if (selection.eaterIds.contains(participantId)) { + selection.eaterIds.remove(participantId) + mealUpdated = true + } + } + if (mealUpdated) { + db.mealDao().insert(meal) + } + } + db.participantTimeDao().deleteByEventAndParticipant(event.uid, participantId) + } + + db.participantDao().deleteById(participantId) + } + + override suspend fun getParticipantById(participantId: String): Participant? { + return db.participantDao().getById(participantId) + } + + override suspend fun findParticipantByName(firstName: String, lastName: String): Participant? { + return db.participantDao().findByName(firstName.trim(), lastName.trim()) + } + + override suspend fun updateParticipantTime(eventId: String, participant: ParticipantTime) { + participant.eventId = eventId + db.participantTimeDao().insert(participant) + } + + // --- Recipes --- + + override suspend fun getAllRecipes(): List { + return db.recipeDao().getAll() + } + + override suspend fun getUserCreatedRecipes(): List { + val userGroup = loginAndRegister.getCustomUserGroup() + return db.recipeDao().getBySource(userGroup) + } + + override suspend fun getRecipeById(recipeId: String): Recipe? { + val recipe = db.recipeDao().getById(recipeId) ?: return null + + val ingredientRefs = recipe.shoppingIngredients.map { it.ingredientRef }.distinct() + if (ingredientRefs.isNotEmpty()) { + val allIngredients = db.ingredientDao().getAll() + val ingredientMap = allIngredients.associateBy { it.uid } + recipe.shoppingIngredients.forEach { si -> + si.ingredient = ingredientMap[si.ingredientRef] + } + } + + return recipe + } + + override suspend fun createRecipe(recipe: Recipe) { + recipe.source = loginAndRegister.getCustomUserGroup() + recipe.shoppingIngredients.forEach { it.ingredient = null } + if (recipe.uid.isBlank()) { + recipe.uid = generateRandomStringId() + } + db.recipeDao().insert(recipe) + } + + override suspend fun updateRecipe(recipe: Recipe) { + recipe.shoppingIngredients.forEach { it.ingredient = null } + db.recipeDao().insert(recipe) + } + + override suspend fun deleteRecipe(recipeId: String) { + db.recipeDao().deleteById(recipeId) + } + + // --- Meals --- + + override suspend fun getAllMealsOfEvent(eventId: String): List { + val meals = db.mealDao().getByEventId(eventId) + + val allRecipeIds = meals.flatMap { it.recipeSelections.map { rs -> rs.recipeRef } }.distinct() + if (allRecipeIds.isNotEmpty()) { + val recipes = db.recipeDao().getAll() + val recipeMap = recipes.associateBy { it.uid } + + val allIngredients = db.ingredientDao().getAll() + val ingredientMap = allIngredients.associateBy { it.uid } + + meals.forEach { meal -> + meal.recipeSelections.forEach { selection -> + val recipe = recipeMap[selection.recipeRef] + if (recipe != null) { + recipe.shoppingIngredients.forEach { si -> + si.ingredient = ingredientMap[si.ingredientRef] + } + selection.recipe = recipe + } + } + } + } + + return meals + } + + override suspend fun getMealById(eventId: String, mealId: String): Meal { + val meal = db.mealDao().getById(eventId, mealId) + ?: throw NoSuchElementException("Meal $mealId not found in event $eventId") + + val recipeIds = meal.recipeSelections.map { it.recipeRef }.distinct() + if (recipeIds.isNotEmpty()) { + val recipes = db.recipeDao().getAll() + val recipeMap = recipes.associateBy { it.uid } + + val allIngredients = db.ingredientDao().getAll() + val ingredientMap = allIngredients.associateBy { it.uid } + + meal.recipeSelections.forEach { selection -> + val recipe = recipeMap[selection.recipeRef] + if (recipe != null) { + recipe.shoppingIngredients.forEach { si -> + si.ingredient = ingredientMap[si.ingredientRef] + } + selection.recipe = recipe + } + } + } + + return meal + } + + override suspend fun createNewMeal(eventId: String, day: Instant): Meal { + val mealId = generateRandomStringId() + val meal = Meal(day = day, uid = mealId).also { it.eventId = eventId } + db.mealDao().insert(meal) + return meal + } + + override suspend fun deleteMeal(eventId: String, mealId: String) { + db.mealDao().deleteById(eventId, mealId) + } + + override suspend fun updateMeal(eventId: String, meal: Meal) { + meal.eventId = eventId + db.mealDao().insert(meal) + } + + // --- Ingredients --- + + override suspend fun getIngredientById(ingredientId: String): Ingredient { + return db.ingredientDao().getById(ingredientId) + ?: throw NoSuchElementException("Ingredient $ingredientId not found") + } + + override suspend fun getMealsWithRecipeAndIngredients(eventId: String): List { + return getAllMealsOfEvent(eventId) + } + + override suspend fun getAllIngredients(): List { + return db.ingredientDao().getAll() + } + + // --- Shopping Lists --- + + override suspend fun getMultiDayShoppingList(eventId: String): MultiDayShoppingList? { + return db.multiDayShoppingListDao().getByEventId(eventId) + } + + override suspend fun saveMultiDayShoppingList( + eventId: String, + multiDayShoppingList: MultiDayShoppingList + ) { + db.multiDayShoppingListDao().insert(multiDayShoppingList) + } + + override suspend fun getDailyShoppingList(eventId: String, date: LocalDate): DailyShoppingList? { + val multiDayList = getMultiDayShoppingList(eventId) + return multiDayList?.dailyLists?.get(date) + } + + override suspend fun saveDailyShoppingList( + eventId: String, + date: LocalDate, + dailyShoppingList: DailyShoppingList + ) { + val multiDayList = getMultiDayShoppingList(eventId) + if (multiDayList != null) { + val updatedDailyLists = multiDayList.dailyLists.toMutableMap() + updatedDailyLists[date] = dailyShoppingList + val updatedMultiDayList = multiDayList.copy(dailyLists = updatedDailyLists) + saveMultiDayShoppingList(eventId, updatedMultiDayList) + } + } + + override suspend fun updateShoppingIngredientStatus( + eventId: String, + date: LocalDate, + ingredientId: String, + completed: Boolean + ) { + val dailyList = getDailyShoppingList(eventId, date) + if (dailyList != null) { + val updatedIngredients = dailyList.ingredients.map { ingredient -> + if (ingredient.uid == ingredientId || ingredient.ingredientRef == ingredientId) { + ingredient.apply { shoppingDone = completed } + } else { + ingredient + } + } + val updatedDailyList = dailyList.copy(ingredients = updatedIngredients) + saveDailyShoppingList(eventId, date, updatedDailyList) + } + } + + override suspend fun deleteShoppingListForDate(eventId: String, date: LocalDate) { + val multiDayList = getMultiDayShoppingList(eventId) + if (multiDayList != null) { + val updatedDailyLists = multiDayList.dailyLists.toMutableMap() + updatedDailyLists.remove(date) + val updatedMultiDayList = multiDayList.copy(dailyLists = updatedDailyLists) + saveMultiDayShoppingList(eventId, updatedMultiDayList) + } + } + + // --- Materials --- + + override suspend fun saveMaterialList(eventId: String, materialList: List) { + materialList.forEach { material -> + db.materialDao().insert(material) + } + } + + override suspend fun getMaterialListOfEvent(eventId: String): List { + // Materials are stored globally; event-scoped material list uses the same table + // For now, return all materials. Event-specific filtering can be added later. + return db.materialDao().getAll() + } + + override suspend fun deleteMaterialById(eventId: String, materialId: String) { + db.materialDao().deleteById(materialId) + } + + override suspend fun getAllMaterials(): List { + return db.materialDao().getAll() + } +} diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/BaseDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/BaseDao.kt new file mode 100644 index 00000000..99352ac8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/BaseDao.kt @@ -0,0 +1,21 @@ +package data.local.dao + +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Update +import androidx.room.Upsert + +interface BaseDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: T) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + @Update + suspend fun update(entity: T) + + @Delete + suspend fun delete(entity: T) +} diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/EventDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/EventDao.kt new file mode 100644 index 00000000..5f7c4c5c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/EventDao.kt @@ -0,0 +1,18 @@ +package data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import model.Event + +@Dao +interface EventDao : BaseDao { + @Query("SELECT * FROM events WHERE `group` = :group") + fun getByGroup(group: String): Flow> + + @Query("SELECT * FROM events WHERE uid = :uid") + suspend fun getById(uid: String): Event? + + @Query("DELETE FROM events WHERE uid = :uid") + suspend fun deleteById(uid: String) +} diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/IngredientDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/IngredientDao.kt new file mode 100644 index 00000000..0029f76b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/IngredientDao.kt @@ -0,0 +1,14 @@ +package data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import model.Ingredient + +@Dao +interface IngredientDao : BaseDao { + @Query("SELECT * FROM ingredients") + suspend fun getAll(): List + + @Query("SELECT * FROM ingredients WHERE uid = :uid") + suspend fun getById(uid: String): Ingredient? +} diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/MaterialDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/MaterialDao.kt new file mode 100644 index 00000000..2852c45f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/MaterialDao.kt @@ -0,0 +1,14 @@ +package data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import model.Material + +@Dao +interface MaterialDao : BaseDao { + @Query("SELECT * FROM materials") + suspend fun getAll(): List + + @Query("DELETE FROM materials WHERE uid = :uid") + suspend fun deleteById(uid: String) +} diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/MealDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/MealDao.kt new file mode 100644 index 00000000..309a5962 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/MealDao.kt @@ -0,0 +1,17 @@ +package data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import model.Meal + +@Dao +interface MealDao : BaseDao { + @Query("SELECT * FROM meals WHERE eventId = :eventId") + suspend fun getByEventId(eventId: String): List + + @Query("SELECT * FROM meals WHERE uid = :uid AND eventId = :eventId") + suspend fun getById(eventId: String, uid: String): Meal? + + @Query("DELETE FROM meals WHERE uid = :uid AND eventId = :eventId") + suspend fun deleteById(eventId: String, uid: String) +} diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/MultiDayShoppingListDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/MultiDayShoppingListDao.kt new file mode 100644 index 00000000..8195deb2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/MultiDayShoppingListDao.kt @@ -0,0 +1,14 @@ +package data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import model.MultiDayShoppingList + +@Dao +interface MultiDayShoppingListDao : BaseDao { + @Query("SELECT * FROM multi_day_shopping_lists WHERE eventId = :eventId") + suspend fun getByEventId(eventId: String): MultiDayShoppingList? + + @Query("DELETE FROM multi_day_shopping_lists WHERE eventId = :eventId") + suspend fun deleteByEventId(eventId: String) +} diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/ParticipantDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/ParticipantDao.kt new file mode 100644 index 00000000..b3e7d044 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/ParticipantDao.kt @@ -0,0 +1,21 @@ +package data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import model.Participant + +@Dao +interface ParticipantDao : BaseDao { + @Query("SELECT * FROM participants WHERE `group` = :group") + fun getByGroup(group: String): Flow> + + @Query("SELECT * FROM participants WHERE uid = :uid") + suspend fun getById(uid: String): Participant? + + @Query("SELECT * FROM participants WHERE firstName = :firstName AND lastName = :lastName LIMIT 1") + suspend fun findByName(firstName: String, lastName: String): Participant? + + @Query("DELETE FROM participants WHERE uid = :uid") + suspend fun deleteById(uid: String) +} diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/ParticipantTimeDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/ParticipantTimeDao.kt new file mode 100644 index 00000000..6488bde2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/ParticipantTimeDao.kt @@ -0,0 +1,20 @@ +package data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import model.ParticipantTime + +@Dao +interface ParticipantTimeDao : BaseDao { + @Query("SELECT * FROM participant_times WHERE eventId = :eventId") + suspend fun getByEventId(eventId: String): List + + @Query("SELECT COUNT(*) FROM participant_times WHERE eventId = :eventId") + suspend fun getCountByEventId(eventId: String): Int + + @Query("DELETE FROM participant_times WHERE eventId = :eventId AND participantRef = :participantId") + suspend fun deleteByEventAndParticipant(eventId: String, participantId: String) + + @Query("DELETE FROM participant_times WHERE participantRef = :participantId") + suspend fun deleteByParticipantId(participantId: String) +} diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt new file mode 100644 index 00000000..9493a49c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt @@ -0,0 +1,20 @@ +package data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import model.Recipe + +@Dao +interface RecipeDao : BaseDao { + @Query("SELECT * FROM recipes") + suspend fun getAll(): List + + @Query("SELECT * FROM recipes WHERE source = :source") + suspend fun getBySource(source: String): List + + @Query("SELECT * FROM recipes WHERE uid = :uid") + suspend fun getById(uid: String): Recipe? + + @Query("DELETE FROM recipes WHERE uid = :uid") + suspend fun deleteById(uid: String) +} diff --git a/composeApp/src/commonMain/kotlin/model/DailyShoppingList.kt b/composeApp/src/commonMain/kotlin/model/DailyShoppingList.kt index e9c947ed..601b6a04 100644 --- a/composeApp/src/commonMain/kotlin/model/DailyShoppingList.kt +++ b/composeApp/src/commonMain/kotlin/model/DailyShoppingList.kt @@ -1,11 +1,10 @@ package model +import androidx.room.Entity +import androidx.room.PrimaryKey import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable -/** - * Represents a shopping list for a specific day with ingredients categorized by purchase optimization - */ @Serializable data class DailyShoppingList( val purchaseDate: LocalDate, @@ -37,12 +36,10 @@ data class DailyShoppingList( fun isCompleted(): Boolean = ingredients.isNotEmpty() && ingredients.all { it.shoppingDone } } -/** - * Container for multi-day shopping lists with utility methods - */ @Serializable +@Entity(tableName = "multi_day_shopping_lists") data class MultiDayShoppingList( - val eventId: String, + @PrimaryKey val eventId: String, val dailyLists: Map = emptyMap() ) { /** diff --git a/composeApp/src/commonMain/kotlin/model/Event.kt b/composeApp/src/commonMain/kotlin/model/Event.kt index 8911fee1..4171aafe 100644 --- a/composeApp/src/commonMain/kotlin/model/Event.kt +++ b/composeApp/src/commonMain/kotlin/model/Event.kt @@ -1,5 +1,7 @@ package model +import androidx.room.Entity +import androidx.room.PrimaryKey import kotlin.time.Clock import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -7,8 +9,9 @@ import view.shared.HelperFunctions import view.shared.list.ListItem @Serializable +@Entity(tableName = "events") class Event(val group: String) : ListItem { - var uid: String = "" + @PrimaryKey var uid: String = "" var from: Instant = Clock.System.now(); var to: Instant = Clock.System.now(); diff --git a/composeApp/src/commonMain/kotlin/model/Ingredient.kt b/composeApp/src/commonMain/kotlin/model/Ingredient.kt index 5143707f..f8e05324 100644 --- a/composeApp/src/commonMain/kotlin/model/Ingredient.kt +++ b/composeApp/src/commonMain/kotlin/model/Ingredient.kt @@ -1,11 +1,14 @@ package model +import androidx.room.Entity +import androidx.room.PrimaryKey import kotlinx.serialization.Serializable import view.shared.list.ListItem @Serializable +@Entity(tableName = "ingredients") class Ingredient : ListItem { - var uid: String = "" + @PrimaryKey var uid: String = "" var name: String = "" var amountHaferl: Double? = null var unitHaferl: IngredientUnit? = null diff --git a/composeApp/src/commonMain/kotlin/model/Material.kt b/composeApp/src/commonMain/kotlin/model/Material.kt index b42a5df0..13e3e8b4 100644 --- a/composeApp/src/commonMain/kotlin/model/Material.kt +++ b/composeApp/src/commonMain/kotlin/model/Material.kt @@ -1,12 +1,15 @@ package model +import androidx.room.Entity +import androidx.room.PrimaryKey import kotlinx.serialization.Serializable import view.shared.HelperFunctions import view.shared.list.ListItem @Serializable +@Entity(tableName = "materials") class Material : ListItem { - var uid = "" + @PrimaryKey var uid: String = "" var name: String = ""; var source: Source = Source.ENTERED_BY_USER var amount: Int = 0 diff --git a/composeApp/src/commonMain/kotlin/model/Meal.kt b/composeApp/src/commonMain/kotlin/model/Meal.kt index a3c23815..236f5591 100644 --- a/composeApp/src/commonMain/kotlin/model/Meal.kt +++ b/composeApp/src/commonMain/kotlin/model/Meal.kt @@ -1,17 +1,23 @@ package model +import androidx.room.Entity +import androidx.room.PrimaryKey import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import view.shared.list.ListItem @Serializable +@Entity(tableName = "meals") data class Meal( - val uid: String = "", + @PrimaryKey val uid: String = "", var day: Instant, var mealType: MealType = MealType.MITTAG, var recipeSelections: List = emptyList(), ) : ListItem { + @kotlinx.serialization.Transient + var eventId: String = "" + override fun getListItemTitle(): String { return mealType.name } diff --git a/composeApp/src/commonMain/kotlin/model/Participant.kt b/composeApp/src/commonMain/kotlin/model/Participant.kt index 2bd5d6d2..df4900fa 100644 --- a/composeApp/src/commonMain/kotlin/model/Participant.kt +++ b/composeApp/src/commonMain/kotlin/model/Participant.kt @@ -1,13 +1,16 @@ package model +import androidx.room.Entity +import androidx.room.PrimaryKey import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import view.shared.HelperFunctions import view.shared.list.ListItem @Serializable +@Entity(tableName = "participants") class Participant : ListItem { - var uid: String = ""; + @PrimaryKey var uid: String = ""; var allergies: List = emptyList(); var intolerances: List = emptyList(); diff --git a/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt b/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt index 563888b2..512c2d12 100644 --- a/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt +++ b/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt @@ -1,5 +1,8 @@ package model +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -7,17 +10,23 @@ import view.shared.HelperFunctions import view.shared.list.ListItem @Serializable +@Entity(tableName = "participant_times") class ParticipantTime( - var uid: String = "", - @Transient - var participant: Participant? = null, + @PrimaryKey var uid: String = "", var from: Instant, var to: Instant, - val participantRef: String, + var participantRef: String = "", var cookingGroup: String = "" ) : ListItem { + @Transient + @Ignore + var participant: Participant? = null + + @Transient + var eventId: String = "" + override fun getListItemTitle(): String { return (this.participant?.firstName?.trim() ?: "") + " " + (this.participant?.lastName?.trim() diff --git a/composeApp/src/commonMain/kotlin/model/RecepieSelection.kt b/composeApp/src/commonMain/kotlin/model/RecepieSelection.kt index a16ab248..ef192eb3 100644 --- a/composeApp/src/commonMain/kotlin/model/RecepieSelection.kt +++ b/composeApp/src/commonMain/kotlin/model/RecepieSelection.kt @@ -1,18 +1,23 @@ package model +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import view.shared.HelperFunctions.Companion.generateRandomStringId @Serializable +@Entity(tableName = "recipe_selections") class RecipeSelection() { - var uid: String = generateRandomStringId() + @PrimaryKey var uid: String = generateRandomStringId() var eaterIds: MutableSet = mutableSetOf() var recipeRef: String = "" var selectedRecipeName: String = "" var guestCount: Int = 0 @Transient + @Ignore var recipe: Recipe? = null } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/model/Recipe.kt b/composeApp/src/commonMain/kotlin/model/Recipe.kt index c383ec2e..31a34a21 100644 --- a/composeApp/src/commonMain/kotlin/model/Recipe.kt +++ b/composeApp/src/commonMain/kotlin/model/Recipe.kt @@ -1,10 +1,13 @@ package model +import androidx.room.Entity +import androidx.room.PrimaryKey import kotlinx.serialization.Serializable @Serializable +@Entity(tableName = "recipes") class Recipe { - var uid: String = "" + @PrimaryKey var uid: String = "" var cookingInstructions: List = listOf() diff --git a/composeApp/src/commonMain/kotlin/model/ShoppingIngredient.kt b/composeApp/src/commonMain/kotlin/model/ShoppingIngredient.kt index edba6233..4783a2df 100644 --- a/composeApp/src/commonMain/kotlin/model/ShoppingIngredient.kt +++ b/composeApp/src/commonMain/kotlin/model/ShoppingIngredient.kt @@ -1,5 +1,8 @@ package model +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey import co.touchlab.kermit.Logger import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -10,12 +13,14 @@ import kotlin.math.round import kotlin.math.roundToInt @Serializable +@Entity(tableName = "shopping_ingredients") class ShoppingIngredient() : ListItem { - var uid: String = "" + @PrimaryKey var uid: String = "" var ingredientRef: String = "" var nameEnteredByUser: String = "" @Transient + @Ignore var ingredient: Ingredient? = null var amount: Double = 0.0; var unit: IngredientUnit = IngredientUnit.GRAMM diff --git a/composeApp/src/commonMain/kotlin/modules/DataModules.kt b/composeApp/src/commonMain/kotlin/modules/DataModules.kt index d677948d..80fa0f02 100644 --- a/composeApp/src/commonMain/kotlin/modules/DataModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/DataModules.kt @@ -1,9 +1,21 @@ package modules +import data.DelegatingRepository import data.EventRepository -import data.FireBaseRepository +import data.local.AppDatabase +import data.local.getDatabaseBuilder +import kotlinx.coroutines.Dispatchers import org.koin.dsl.module val dataModules = module { - single { FireBaseRepository(get()) } + single { + getDatabaseBuilder() + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + } + + single { + DelegatingRepository(get(), get(), get()) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt b/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt index c38fdc22..fbd8b84e 100644 --- a/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt @@ -1,17 +1,19 @@ package modules import org.koin.dsl.module +import services.SeedDataService import services.shoppingList.CalculateShoppingList import services.ChangeDateOfEvent import services.login.LoginAndRegister -import services.login.FirebaseLoginAndRegister +import services.login.DelegatingLoginAndRegister import services.materiallist.CalculateMaterialList import services.pdfService.PdfServiceModule import services.event.ParticipantCanEatRecipe import services.update.UpdateChecker val serviceModules = module { - single { FirebaseLoginAndRegister() } + single { DelegatingLoginAndRegister(get()) } + single { SeedDataService(get(), get()) } single { CalculateShoppingList(get()) } single { CalculateMaterialList(get()) } single { PdfServiceModule(get(), get()) } diff --git a/composeApp/src/commonMain/kotlin/services/SeedDataService.kt b/composeApp/src/commonMain/kotlin/services/SeedDataService.kt new file mode 100644 index 00000000..12d34850 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/services/SeedDataService.kt @@ -0,0 +1,52 @@ +package services + +import co.touchlab.kermit.Logger +import data.AppModePreferences +import data.FireBaseRepository +import data.local.AppDatabase +import services.login.FirebaseLoginAndRegister + +class SeedDataService( + private val db: AppDatabase, + private val prefs: AppModePreferences +) { + suspend fun downloadSeedDataIfNeeded(): Boolean { + if (prefs.isSeedDataDownloaded()) return true + + return try { + val firebaseRepo = FireBaseRepository(FirebaseLoginAndRegister()) + + val recipes = firebaseRepo.getAllRecipes() + Logger.i("SeedData: Downloaded ${recipes.size} recipes") + recipes.forEach { recipe -> + recipe.shoppingIngredients.forEach { it.ingredient = null } + if (recipe.uid.isNotBlank()) { + db.recipeDao().insert(recipe) + } + } + + val ingredients = firebaseRepo.getAllIngredients() + Logger.i("SeedData: Downloaded ${ingredients.size} ingredients") + ingredients.forEach { ingredient -> + if (ingredient.uid.isNotBlank()) { + db.ingredientDao().insert(ingredient) + } + } + + val materials = firebaseRepo.getAllMaterials() + Logger.i("SeedData: Downloaded ${materials.size} materials") + materials.forEach { material -> + if (material.uid.isNotBlank()) { + db.materialDao().insert(material) + } + } + + prefs.setSeedDataDownloaded(true) + Logger.i("SeedData: Download complete") + true + } catch (e: Exception) { + Logger.e("SeedData: Download failed: ${e.message}", e) + false + } + } +} diff --git a/composeApp/src/commonMain/kotlin/services/login/DelegatingLoginAndRegister.kt b/composeApp/src/commonMain/kotlin/services/login/DelegatingLoginAndRegister.kt new file mode 100644 index 00000000..b0c8807c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/services/login/DelegatingLoginAndRegister.kt @@ -0,0 +1,25 @@ +package services.login + +import data.AppMode +import data.AppModeHolder + +class DelegatingLoginAndRegister( + private val appModeHolder: AppModeHolder +) : LoginAndRegister { + + private val firebase by lazy { FirebaseLoginAndRegister() } + private val offline = OfflineLoginAndRegister() + + private val active: LoginAndRegister + get() = when (appModeHolder.mode.value) { + AppMode.OFFLINE_ONLY -> offline + else -> firebase + } + + override suspend fun login(email: String, password: String) = active.login(email, password) + override suspend fun register(email: String, password: String, group: String) = active.register(email, password, group) + override fun isAuthenticated(): Boolean = active.isAuthenticated() + override suspend fun logout() = active.logout() + override suspend fun getCustomUserGroup(): String = active.getCustomUserGroup() + override suspend fun deleteCurrentUser() = active.deleteCurrentUser() +} diff --git a/composeApp/src/commonMain/kotlin/services/login/OfflineLoginAndRegister.kt b/composeApp/src/commonMain/kotlin/services/login/OfflineLoginAndRegister.kt new file mode 100644 index 00000000..7c3e2ae9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/services/login/OfflineLoginAndRegister.kt @@ -0,0 +1,24 @@ +package services.login + +class OfflineLoginAndRegister : LoginAndRegister { + + override suspend fun login(email: String, password: String) { + // No-op + } + + override suspend fun register(email: String, password: String, group: String) { + // No-op + } + + override fun isAuthenticated(): Boolean = true + + override suspend fun logout() { + // No-op + } + + override suspend fun getCustomUserGroup(): String = "offline_local" + + override suspend fun deleteCurrentUser() { + // No-op + } +} diff --git a/composeApp/src/commonMain/kotlin/view/login/Login.kt b/composeApp/src/commonMain/kotlin/view/login/Login.kt index 52bbf04d..13f7c0b1 100644 --- a/composeApp/src/commonMain/kotlin/view/login/Login.kt +++ b/composeApp/src/commonMain/kotlin/view/login/Login.kt @@ -63,6 +63,7 @@ import view.shared.MGCircularProgressIndicator fun LoginScreen( navigateToRegister: () -> Unit, navigateToHome: () -> Unit, + onOfflineSelected: (suspend () -> Unit)? = null, ) { var loading by rememberSaveable { mutableStateOf(false) } var email by rememberSaveable { mutableStateOf("") } @@ -91,6 +92,21 @@ fun LoginScreen( isSubmitEnabled = isSubmitEnabled, loginError = loginError, navigateToRegister = navigateToRegister, + onOfflineSelected = if (onOfflineSelected != null) { + { + scope.launch { + loading = true + loginError = "" + try { + onOfflineSelected() + } catch (e: Exception) { + loginError = "Fehler beim Laden der Daten. Bitte überprüfe deine Internetverbindung." + } finally { + loading = false + } + } + } + } else null, onSubmit = { scope.launch { loading = true @@ -122,6 +138,7 @@ fun LoginContent( isSubmitEnabled: Boolean, loginError: String, navigateToRegister: () -> Unit, + onOfflineSelected: (() -> Unit)? = null, ) { val focusManager by rememberUpdatedState(LocalFocusManager.current) @@ -175,6 +192,21 @@ fun LoginContent( Spacer(modifier = Modifier.padding(16.dp)) RegisterLink(onNavigateToRegister = navigateToRegister) + if (onOfflineSelected != null) { + Spacer(modifier = Modifier.padding(8.dp)) + androidx.compose.material3.OutlinedButton( + onClick = onOfflineSelected, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "App offline nutzen") + } + Text( + text = "Daten werden nur lokal gespeichert. Beim ersten Start werden alle Rezepte und Zutaten einmalig heruntergeladen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt b/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt index bfc0e26c..6c8938c9 100644 --- a/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt +++ b/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt @@ -7,8 +7,11 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import data.AppMode +import data.AppModeHolder import data.EventRepository import org.koin.compose.koinInject +import services.SeedDataService import services.login.LoginAndRegister import view.admin.csv_import.CsvImportScreen import view.admin.new_participant.NewParticipantScreen @@ -37,10 +40,12 @@ fun RootNavController( val navController = rememberNavController() val loginService = koinInject() val eventRepository = koinInject() + val appModeHolder = koinInject() + val seedDataService = koinInject() NavHost( navController = navController, - startDestination = getStartDestination(loginService) + startDestination = getStartDestination(loginService, appModeHolder.mode.value) ) { composable { LoadingScreen() @@ -48,7 +53,14 @@ fun RootNavController( composable { LoginScreen( navigateToRegister = { navController.navigate(Routes.Register) }, - navigateToHome = { navController.navigate(Routes.Home) } + navigateToHome = { navController.navigate(Routes.Home) }, + onOfflineSelected = { + seedDataService.downloadSeedDataIfNeeded() + appModeHolder.switchMode(AppMode.OFFLINE_ONLY) + navController.navigate(Routes.Home) { + popUpTo(Routes.Login) { inclusive = true } + } + } ) } composable { @@ -126,9 +138,8 @@ fun RootNavController( } } -fun getStartDestination(loginService: LoginAndRegister): Any { - if (loginService.isAuthenticated()) { - return Routes.Home - } +fun getStartDestination(loginService: LoginAndRegister, appMode: AppMode): Any { + if (appMode == AppMode.OFFLINE_ONLY) return Routes.Home + if (loginService.isAuthenticated()) return Routes.Home return Routes.Login } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/data/AppModePreferences.desktop.kt b/composeApp/src/desktopMain/kotlin/data/AppModePreferences.desktop.kt new file mode 100644 index 00000000..d0a9220a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/data/AppModePreferences.desktop.kt @@ -0,0 +1,24 @@ +package data + +import java.util.prefs.Preferences + +actual class AppModePreferences actual constructor() { + private val prefs = Preferences.userNodeForPackage(AppModePreferences::class.java) + + actual fun getAppMode(): AppMode { + val name = prefs.get("app_mode", null) ?: return AppMode.ONLINE + return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.ONLINE } + } + + actual fun setAppMode(mode: AppMode) { + prefs.put("app_mode", mode.name) + } + + actual fun isSeedDataDownloaded(): Boolean { + return prefs.getBoolean("seed_data_downloaded", false) + } + + actual fun setSeedDataDownloaded(downloaded: Boolean) { + prefs.putBoolean("seed_data_downloaded", downloaded) + } +} diff --git a/composeApp/src/desktopMain/kotlin/data/local/DatabaseBuilder.desktop.kt b/composeApp/src/desktopMain/kotlin/data/local/DatabaseBuilder.desktop.kt new file mode 100644 index 00000000..44592bd1 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/data/local/DatabaseBuilder.desktop.kt @@ -0,0 +1,14 @@ +package data.local + +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import java.io.File + +actual fun getDatabaseBuilder(): RoomDatabase.Builder { + val dbDir = File(System.getProperty("user.home"), ".futterbock") + dbDir.mkdirs() + val dbFile = File(dbDir, "futterbock.db") + return Room.databaseBuilder(name = dbFile.absolutePath) + .setDriver(BundledSQLiteDriver()) +} diff --git a/composeApp/src/iosMain/kotlin/data/AppModePreferences.ios.kt b/composeApp/src/iosMain/kotlin/data/AppModePreferences.ios.kt new file mode 100644 index 00000000..51993918 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/data/AppModePreferences.ios.kt @@ -0,0 +1,24 @@ +package data + +import platform.Foundation.NSUserDefaults + +actual class AppModePreferences actual constructor() { + private val defaults = NSUserDefaults.standardUserDefaults + + actual fun getAppMode(): AppMode { + val name = defaults.stringForKey("app_mode") ?: return AppMode.ONLINE + return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.ONLINE } + } + + actual fun setAppMode(mode: AppMode) { + defaults.setObject(mode.name, forKey = "app_mode") + } + + actual fun isSeedDataDownloaded(): Boolean { + return defaults.boolForKey("seed_data_downloaded") + } + + actual fun setSeedDataDownloaded(downloaded: Boolean) { + defaults.setBool(downloaded, forKey = "seed_data_downloaded") + } +} diff --git a/composeApp/src/iosMain/kotlin/data/local/DatabaseBuilder.ios.kt b/composeApp/src/iosMain/kotlin/data/local/DatabaseBuilder.ios.kt new file mode 100644 index 00000000..65f8a1f5 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/data/local/DatabaseBuilder.ios.kt @@ -0,0 +1,12 @@ +package data.local + +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import platform.Foundation.NSHomeDirectory + +actual fun getDatabaseBuilder(): RoomDatabase.Builder { + val dbPath = NSHomeDirectory() + "/Documents/futterbock.db" + return Room.databaseBuilder(name = dbPath) + .setDriver(BundledSQLiteDriver()) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f13e89f6..8cf3fe75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,9 @@ junitJupiter = "5.13.3" buildkonfig = "0.16.0" resolver = "1.0.0" ktor = "3.0.3" +room = "2.8.4" +ksp = "2.2.0-2.0.2" +sqlite = "2.5.1" [libraries] kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } @@ -65,6 +68,11 @@ junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.r koinTest = { module = "io.insert-koin:koin-test", version.ref = "koin" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-x-coroutines" } +##Room +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } + ##Ktor for update checker ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } @@ -81,3 +89,5 @@ google-services = { id = "com.google.gms.google-services", version.ref = "google kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfig" } resolver = { id = "org.gradle.toolchains.foojay-resolver-convention", version.ref = "resolver" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +room = { id = "androidx.room", version.ref = "room" } From 0eb1d86c128fe393ece0866549587a296365f39e Mon Sep 17 00:00:00 2001 From: Frederik Kischewski Date: Mon, 4 May 2026 20:21:03 +0200 Subject: [PATCH 2/9] feat: add offline-first mode with sync, foreign keys, and code cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OfflineFirstRepository: reads from Firebase when online (caches in Room), falls back to Room when offline, queues writes for sync - Add NetworkMonitor (expect/actual) for Android, Desktop, iOS - Add PendingOperation table and SyncManager for queued write replay - Add ForeignKeys on Meal.eventId and ParticipantTime (CASCADE delete) - Remove @Entity from ShoppingIngredient/RecipeSelection (stored as JSON) - Fix insert→update for existing entity updates in RoomRepository - Wire all dependencies through Koin (no manual instantiation) - Update login screen offline hint with internet requirement note --- .../schemas/data.local.AppDatabase/2.json | 507 ++++++++++++++++ .../schemas/data.local.AppDatabase/3.json | 554 ++++++++++++++++++ .../data/sync/NetworkMonitorImpl.android.kt | 41 ++ composeApp/src/commonMain/kotlin/App.kt | 9 + .../kotlin/data/DelegatingRepository.kt | 17 +- .../kotlin/data/local/AppDatabase.kt | 7 +- .../kotlin/data/local/RoomRepository.kt | 12 +- .../data/local/dao/PendingOperationDao.kt | 24 + .../kotlin/data/sync/NetworkMonitor.kt | 9 + .../data/sync/OfflineFirstRepository.kt | 429 ++++++++++++++ .../kotlin/data/sync/PendingOperation.kt | 14 + .../kotlin/data/sync/SyncManager.kt | 116 ++++ .../src/commonMain/kotlin/model/Meal.kt | 11 +- .../kotlin/model/ParticipantTime.kt | 19 +- .../kotlin/model/RecepieSelection.kt | 7 +- .../kotlin/model/ShoppingIngredient.kt | 7 +- .../commonMain/kotlin/modules/DataModules.kt | 18 +- .../kotlin/modules/ServiceModules.kt | 4 +- .../kotlin/services/SeedDataService.kt | 31 +- .../login/DelegatingLoginAndRegister.kt | 7 +- .../src/commonMain/kotlin/view/login/Login.kt | 2 +- .../data/sync/NetworkMonitorImpl.desktop.kt | 35 ++ .../data/sync/NetworkMonitorImpl.ios.kt | 26 + 23 files changed, 1845 insertions(+), 61 deletions(-) create mode 100644 composeApp/schemas/data.local.AppDatabase/2.json create mode 100644 composeApp/schemas/data.local.AppDatabase/3.json create mode 100644 composeApp/src/androidMain/kotlin/data/sync/NetworkMonitorImpl.android.kt create mode 100644 composeApp/src/commonMain/kotlin/data/local/dao/PendingOperationDao.kt create mode 100644 composeApp/src/commonMain/kotlin/data/sync/NetworkMonitor.kt create mode 100644 composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/data/sync/PendingOperation.kt create mode 100644 composeApp/src/commonMain/kotlin/data/sync/SyncManager.kt create mode 100644 composeApp/src/desktopMain/kotlin/data/sync/NetworkMonitorImpl.desktop.kt create mode 100644 composeApp/src/iosMain/kotlin/data/sync/NetworkMonitorImpl.ios.kt diff --git a/composeApp/schemas/data.local.AppDatabase/2.json b/composeApp/schemas/data.local.AppDatabase/2.json new file mode 100644 index 00000000..a7b81916 --- /dev/null +++ b/composeApp/schemas/data.local.AppDatabase/2.json @@ -0,0 +1,507 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "d06105c7c9b78412956b889939eb1efb", + "entities": [ + { + "tableName": "events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` TEXT NOT NULL, `uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `eventType` TEXT NOT NULL, `kitchenSchedule` TEXT, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kitchenSchedule", + "columnName": "kitchenSchedule", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "participants", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `allergies` TEXT NOT NULL, `intolerances` TEXT NOT NULL, `group` TEXT NOT NULL, `selectedGroup` TEXT NOT NULL, `birthdate` INTEGER, `eatingHabit` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "allergies", + "columnName": "allergies", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "intolerances", + "columnName": "intolerances", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedGroup", + "columnName": "selectedGroup", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthdate", + "columnName": "birthdate", + "affinity": "INTEGER" + }, + { + "fieldPath": "eatingHabit", + "columnName": "eatingHabit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "recipes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `cookingInstructions` TEXT NOT NULL, `notes` TEXT NOT NULL, `description` TEXT NOT NULL, `dietaryHabit` TEXT NOT NULL, `shoppingIngredients` TEXT NOT NULL, `materials` TEXT NOT NULL, `name` TEXT NOT NULL, `pageInCookbook` INTEGER NOT NULL, `price` TEXT NOT NULL, `season` TEXT NOT NULL, `foodIntolerances` TEXT NOT NULL, `source` TEXT NOT NULL, `time` TEXT NOT NULL, `skillLevel` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookingInstructions", + "columnName": "cookingInstructions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dietaryHabit", + "columnName": "dietaryHabit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shoppingIngredients", + "columnName": "shoppingIngredients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "materials", + "columnName": "materials", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageInCookbook", + "columnName": "pageInCookbook", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "season", + "columnName": "season", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "foodIntolerances", + "columnName": "foodIntolerances", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "skillLevel", + "columnName": "skillLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "ingredients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `amountHaferl` REAL, `unitHaferl` TEXT, `amountTablespoon` REAL, `unitTablespoon` TEXT, `amountTeaspoon` REAL, `unitTeaspoon` TEXT, `amountWeight` REAL, `unitWeight` TEXT, `category` TEXT NOT NULL, `expirationDateInDays` INTEGER, `intolerances` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountHaferl", + "columnName": "amountHaferl", + "affinity": "REAL" + }, + { + "fieldPath": "unitHaferl", + "columnName": "unitHaferl", + "affinity": "TEXT" + }, + { + "fieldPath": "amountTablespoon", + "columnName": "amountTablespoon", + "affinity": "REAL" + }, + { + "fieldPath": "unitTablespoon", + "columnName": "unitTablespoon", + "affinity": "TEXT" + }, + { + "fieldPath": "amountTeaspoon", + "columnName": "amountTeaspoon", + "affinity": "REAL" + }, + { + "fieldPath": "unitTeaspoon", + "columnName": "unitTeaspoon", + "affinity": "TEXT" + }, + { + "fieldPath": "amountWeight", + "columnName": "amountWeight", + "affinity": "REAL" + }, + { + "fieldPath": "unitWeight", + "columnName": "unitWeight", + "affinity": "TEXT" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationDateInDays", + "columnName": "expirationDateInDays", + "affinity": "INTEGER" + }, + { + "fieldPath": "intolerances", + "columnName": "intolerances", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "meals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `day` INTEGER NOT NULL, `mealType` TEXT NOT NULL, `recipeSelections` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mealType", + "columnName": "mealType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipeSelections", + "columnName": "recipeSelections", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "foreignKeys": [ + { + "table": "events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "participant_times", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `participantRef` TEXT NOT NULL, `cookingGroup` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`participantRef`) REFERENCES `participants`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantRef", + "columnName": "participantRef", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookingGroup", + "columnName": "cookingGroup", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "foreignKeys": [ + { + "table": "events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "participants", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantRef" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "materials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `amount` INTEGER NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "multi_day_shopping_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `dailyLists` TEXT NOT NULL, PRIMARY KEY(`eventId`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dailyLists", + "columnName": "dailyLists", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd06105c7c9b78412956b889939eb1efb')" + ] + } +} \ No newline at end of file diff --git a/composeApp/schemas/data.local.AppDatabase/3.json b/composeApp/schemas/data.local.AppDatabase/3.json new file mode 100644 index 00000000..03ad2090 --- /dev/null +++ b/composeApp/schemas/data.local.AppDatabase/3.json @@ -0,0 +1,554 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "5ef09d435f0c553163086b30fa42993d", + "entities": [ + { + "tableName": "events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` TEXT NOT NULL, `uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `eventType` TEXT NOT NULL, `kitchenSchedule` TEXT, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kitchenSchedule", + "columnName": "kitchenSchedule", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "participants", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `allergies` TEXT NOT NULL, `intolerances` TEXT NOT NULL, `group` TEXT NOT NULL, `selectedGroup` TEXT NOT NULL, `birthdate` INTEGER, `eatingHabit` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "allergies", + "columnName": "allergies", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "intolerances", + "columnName": "intolerances", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedGroup", + "columnName": "selectedGroup", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthdate", + "columnName": "birthdate", + "affinity": "INTEGER" + }, + { + "fieldPath": "eatingHabit", + "columnName": "eatingHabit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "recipes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `cookingInstructions` TEXT NOT NULL, `notes` TEXT NOT NULL, `description` TEXT NOT NULL, `dietaryHabit` TEXT NOT NULL, `shoppingIngredients` TEXT NOT NULL, `materials` TEXT NOT NULL, `name` TEXT NOT NULL, `pageInCookbook` INTEGER NOT NULL, `price` TEXT NOT NULL, `season` TEXT NOT NULL, `foodIntolerances` TEXT NOT NULL, `source` TEXT NOT NULL, `time` TEXT NOT NULL, `skillLevel` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookingInstructions", + "columnName": "cookingInstructions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dietaryHabit", + "columnName": "dietaryHabit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shoppingIngredients", + "columnName": "shoppingIngredients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "materials", + "columnName": "materials", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageInCookbook", + "columnName": "pageInCookbook", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "season", + "columnName": "season", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "foodIntolerances", + "columnName": "foodIntolerances", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "skillLevel", + "columnName": "skillLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "ingredients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `amountHaferl` REAL, `unitHaferl` TEXT, `amountTablespoon` REAL, `unitTablespoon` TEXT, `amountTeaspoon` REAL, `unitTeaspoon` TEXT, `amountWeight` REAL, `unitWeight` TEXT, `category` TEXT NOT NULL, `expirationDateInDays` INTEGER, `intolerances` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountHaferl", + "columnName": "amountHaferl", + "affinity": "REAL" + }, + { + "fieldPath": "unitHaferl", + "columnName": "unitHaferl", + "affinity": "TEXT" + }, + { + "fieldPath": "amountTablespoon", + "columnName": "amountTablespoon", + "affinity": "REAL" + }, + { + "fieldPath": "unitTablespoon", + "columnName": "unitTablespoon", + "affinity": "TEXT" + }, + { + "fieldPath": "amountTeaspoon", + "columnName": "amountTeaspoon", + "affinity": "REAL" + }, + { + "fieldPath": "unitTeaspoon", + "columnName": "unitTeaspoon", + "affinity": "TEXT" + }, + { + "fieldPath": "amountWeight", + "columnName": "amountWeight", + "affinity": "REAL" + }, + { + "fieldPath": "unitWeight", + "columnName": "unitWeight", + "affinity": "TEXT" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationDateInDays", + "columnName": "expirationDateInDays", + "affinity": "INTEGER" + }, + { + "fieldPath": "intolerances", + "columnName": "intolerances", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "meals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `day` INTEGER NOT NULL, `mealType` TEXT NOT NULL, `recipeSelections` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mealType", + "columnName": "mealType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipeSelections", + "columnName": "recipeSelections", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "foreignKeys": [ + { + "table": "events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "participant_times", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `participantRef` TEXT NOT NULL, `cookingGroup` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`participantRef`) REFERENCES `participants`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantRef", + "columnName": "participantRef", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookingGroup", + "columnName": "cookingGroup", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "foreignKeys": [ + { + "table": "events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "participants", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantRef" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "materials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `amount` INTEGER NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "multi_day_shopping_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `dailyLists` TEXT NOT NULL, PRIMARY KEY(`eventId`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dailyLists", + "columnName": "dailyLists", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId" + ] + } + }, + { + "tableName": "pending_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `operationType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `parentId` TEXT, `payloadJson` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "operationType", + "columnName": "operationType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5ef09d435f0c553163086b30fa42993d')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/data/sync/NetworkMonitorImpl.android.kt b/composeApp/src/androidMain/kotlin/data/sync/NetworkMonitorImpl.android.kt new file mode 100644 index 00000000..d496a43d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/data/sync/NetworkMonitorImpl.android.kt @@ -0,0 +1,41 @@ +package data.sync + +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.futterbock.app.MainActivity + +actual class NetworkMonitorImpl actual constructor() : NetworkMonitor { + private val _isOnline = MutableStateFlow(checkInitialState()) + override val isOnline: StateFlow = _isOnline.asStateFlow() + + private val connectivityManager by lazy { + MainActivity.appContext.getSystemService(ConnectivityManager::class.java) + } + + init { + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + _isOnline.value = true + } + + override fun onLost(network: Network) { + _isOnline.value = false + } + }) + } + + private fun checkInitialState(): Boolean { + val cm = MainActivity.appContext.getSystemService(ConnectivityManager::class.java) + val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) + return capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } +} diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index 40181010..adbdb935 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -1,8 +1,10 @@ import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import data.AppMode import data.AppModeHolder import data.AppModePreferences +import data.sync.SyncManager import modules.dataModules import modules.serviceModules import modules.viewModelModules @@ -35,6 +37,13 @@ fun App(pdfService: PdfServiceImpl) { val pdfServiceModule: PdfServiceModule = koinInject() pdfServiceModule.setPdfService(pdfService) + val syncManager = koinInject() + LaunchedEffect(Unit) { + if (appModeHolder.mode.value == AppMode.OFFLINE_FIRST) { + syncManager.startObserving() + } + } + AppTheme { RootNavController() } diff --git a/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt b/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt index c88dc65e..b5186b35 100644 --- a/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt +++ b/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt @@ -1,29 +1,24 @@ package data -import data.local.AppDatabase import data.local.RoomRepository +import data.sync.OfflineFirstRepository import kotlinx.coroutines.flow.Flow import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import model.* -import services.login.LoginAndRegister -import services.login.OfflineLoginAndRegister class DelegatingRepository( private val appModeHolder: AppModeHolder, - private val db: AppDatabase, - private val firebaseLoginAndRegister: LoginAndRegister + private val firebaseRepository: FireBaseRepository, + private val roomRepository: RoomRepository, + private val offlineFirstRepository: OfflineFirstRepository ) : EventRepository { - private val offlineLogin = OfflineLoginAndRegister() - - private val firebaseRepository by lazy { FireBaseRepository(firebaseLoginAndRegister) } - private val roomRepository by lazy { RoomRepository(db, offlineLogin) } - private val active: EventRepository get() = when (appModeHolder.mode.value) { + AppMode.ONLINE -> firebaseRepository AppMode.OFFLINE_ONLY -> roomRepository - else -> firebaseRepository + AppMode.OFFLINE_FIRST -> offlineFirstRepository } override suspend fun deleteEvent(eventId: String) = active.deleteEvent(eventId) diff --git a/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt b/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt index 9846dfd8..5b0f4749 100644 --- a/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt +++ b/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt @@ -4,6 +4,7 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import data.local.dao.* +import data.sync.PendingOperation import model.* @Database( @@ -13,13 +14,12 @@ import model.* Recipe::class, Ingredient::class, Meal::class, - ShoppingIngredient::class, - RecipeSelection::class, ParticipantTime::class, Material::class, MultiDayShoppingList::class, + PendingOperation::class, ], - version = 1, + version = 3, exportSchema = true ) @TypeConverters(Converters::class) @@ -32,4 +32,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun participantTimeDao(): ParticipantTimeDao abstract fun materialDao(): MaterialDao abstract fun multiDayShoppingListDao(): MultiDayShoppingListDao + abstract fun pendingOperationDao(): PendingOperationDao } diff --git a/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt b/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt index 6fb80a3d..bc63f69b 100644 --- a/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt +++ b/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt @@ -41,7 +41,7 @@ class RoomRepository( } override suspend fun saveExistingEvent(event: Event) { - db.eventDao().insert(event) + db.eventDao().update(event) } override suspend fun getEventList(group: String): Flow> { @@ -133,7 +133,7 @@ class RoomRepository( override suspend fun updateParticipant(participant: Participant) { participant.group = loginAndRegister.getCustomUserGroup() - db.participantDao().insert(participant) + db.participantDao().update(participant) } override suspend fun deleteParticipant(participantId: String) { @@ -153,7 +153,7 @@ class RoomRepository( } } if (mealUpdated) { - db.mealDao().insert(meal) + db.mealDao().update(meal) } } db.participantTimeDao().deleteByEventAndParticipant(event.uid, participantId) @@ -172,7 +172,7 @@ class RoomRepository( override suspend fun updateParticipantTime(eventId: String, participant: ParticipantTime) { participant.eventId = eventId - db.participantTimeDao().insert(participant) + db.participantTimeDao().update(participant) } // --- Recipes --- @@ -212,7 +212,7 @@ class RoomRepository( override suspend fun updateRecipe(recipe: Recipe) { recipe.shoppingIngredients.forEach { it.ingredient = null } - db.recipeDao().insert(recipe) + db.recipeDao().update(recipe) } override suspend fun deleteRecipe(recipeId: String) { @@ -287,7 +287,7 @@ class RoomRepository( override suspend fun updateMeal(eventId: String, meal: Meal) { meal.eventId = eventId - db.mealDao().insert(meal) + db.mealDao().update(meal) } // --- Ingredients --- diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/PendingOperationDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/PendingOperationDao.kt new file mode 100644 index 00000000..abef0c0e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/PendingOperationDao.kt @@ -0,0 +1,24 @@ +package data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import data.sync.PendingOperation + +@Dao +interface PendingOperationDao { + @Insert + suspend fun insert(operation: PendingOperation) + + @Query("SELECT * FROM pending_operations ORDER BY timestamp ASC") + suspend fun getAllOrdered(): List + + @Query("DELETE FROM pending_operations WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("DELETE FROM pending_operations") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM pending_operations") + suspend fun count(): Int +} diff --git a/composeApp/src/commonMain/kotlin/data/sync/NetworkMonitor.kt b/composeApp/src/commonMain/kotlin/data/sync/NetworkMonitor.kt new file mode 100644 index 00000000..714968b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/sync/NetworkMonitor.kt @@ -0,0 +1,9 @@ +package data.sync + +import kotlinx.coroutines.flow.StateFlow + +interface NetworkMonitor { + val isOnline: StateFlow +} + +expect class NetworkMonitorImpl() : NetworkMonitor diff --git a/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt b/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt new file mode 100644 index 00000000..a851f013 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt @@ -0,0 +1,429 @@ +package data.sync + +import co.touchlab.kermit.Logger +import data.EventRepository +import data.FireBaseRepository +import data.local.AppDatabase +import data.local.RoomRepository +import kotlin.time.Clock +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import model.* + +class OfflineFirstRepository( + private val db: AppDatabase, + private val firebaseRepository: FireBaseRepository, + private val roomRepository: RoomRepository, + private val networkMonitor: NetworkMonitor +) : EventRepository { + + private val json = Json { ignoreUnknownKeys = true } + + private val isOnline: Boolean get() = networkMonitor.isOnline.value + + private suspend fun queueOperation( + type: String, + entityId: String, + parentId: String? = null, + payload: String = "" + ) { + db.pendingOperationDao().insert( + PendingOperation( + operationType = type, + entityId = entityId, + parentId = parentId, + payloadJson = payload, + timestamp = Clock.System.now().toEpochMilliseconds() + ) + ) + } + + // --- Events --- + + override suspend fun deleteEvent(eventId: String) { + roomRepository.deleteEvent(eventId) + if (isOnline) { + try { firebaseRepository.deleteEvent(eventId) } + catch (e: Exception) { queueOperation("DELETE_EVENT", eventId) } + } else { + queueOperation("DELETE_EVENT", eventId) + } + } + + override suspend fun getEventById(eventId: String): Event? { + if (isOnline) { + try { + val event = firebaseRepository.getEventById(eventId) + if (event != null) roomRepository.saveExistingEvent(event) + return event + } catch (_: Exception) {} + } + return roomRepository.getEventById(eventId) + } + + override suspend fun createNewEvent(): Event { + val event = roomRepository.createNewEvent() + if (isOnline) { + try { firebaseRepository.saveExistingEvent(event) } + catch (e: Exception) { queueOperation("CREATE_EVENT", event.uid, payload = json.encodeToString(event)) } + } else { + queueOperation("CREATE_EVENT", event.uid, payload = json.encodeToString(event)) + } + return event + } + + override suspend fun saveExistingEvent(event: Event) { + roomRepository.saveExistingEvent(event) + if (isOnline) { + try { firebaseRepository.saveExistingEvent(event) } + catch (e: Exception) { queueOperation("UPDATE_EVENT", event.uid, payload = json.encodeToString(event)) } + } else { + queueOperation("UPDATE_EVENT", event.uid, payload = json.encodeToString(event)) + } + } + + override suspend fun getEventList(group: String): Flow> { + if (isOnline) { + try { + return firebaseRepository.getEventList(group) + } catch (_: Exception) {} + } + return roomRepository.getEventList(group) + } + + // --- Participants --- + + override suspend fun getNumberOfParticipants(eventId: String): Int { + if (isOnline) { + try { return firebaseRepository.getNumberOfParticipants(eventId) } + catch (_: Exception) {} + } + return roomRepository.getNumberOfParticipants(eventId) + } + + override suspend fun getParticipantsOfEvent(eventId: String, withParticipant: Boolean): List { + if (isOnline) { + try { return firebaseRepository.getParticipantsOfEvent(eventId, withParticipant) } + catch (_: Exception) {} + } + return roomRepository.getParticipantsOfEvent(eventId, withParticipant) + } + + override suspend fun getAllParticipantsOfStamm(): Flow> { + if (isOnline) { + try { return firebaseRepository.getAllParticipantsOfStamm() } + catch (_: Exception) {} + } + return roomRepository.getAllParticipantsOfStamm() + } + + override suspend fun deleteParticipantOfEvent(eventId: String, participantId: String) { + roomRepository.deleteParticipantOfEvent(eventId, participantId) + if (isOnline) { + try { firebaseRepository.deleteParticipantOfEvent(eventId, participantId) } + catch (e: Exception) { queueOperation("DELETE_PARTICIPANT_OF_EVENT", participantId, parentId = eventId) } + } else { + queueOperation("DELETE_PARTICIPANT_OF_EVENT", participantId, parentId = eventId) + } + } + + override suspend fun addParticipantToEvent(newParticipant: Participant, event: Event): ParticipantTime { + val pt = roomRepository.addParticipantToEvent(newParticipant, event) + if (isOnline) { + try { firebaseRepository.addParticipantToEvent(newParticipant, event) } + catch (e: Exception) { queueOperation("ADD_PARTICIPANT_TO_EVENT", pt.uid, parentId = event.uid, payload = json.encodeToString(pt)) } + } else { + queueOperation("ADD_PARTICIPANT_TO_EVENT", pt.uid, parentId = event.uid, payload = json.encodeToString(pt)) + } + return pt + } + + override suspend fun createNewParticipant(participant: Participant): Participant? { + val result = roomRepository.createNewParticipant(participant) ?: return null + if (isOnline) { + try { firebaseRepository.createNewParticipant(result) } + catch (e: Exception) { queueOperation("CREATE_PARTICIPANT", result.uid, payload = json.encodeToString(result)) } + } else { + queueOperation("CREATE_PARTICIPANT", result.uid, payload = json.encodeToString(result)) + } + return result + } + + override suspend fun updateParticipant(participant: Participant) { + roomRepository.updateParticipant(participant) + if (isOnline) { + try { firebaseRepository.updateParticipant(participant) } + catch (e: Exception) { queueOperation("UPDATE_PARTICIPANT", participant.uid, payload = json.encodeToString(participant)) } + } else { + queueOperation("UPDATE_PARTICIPANT", participant.uid, payload = json.encodeToString(participant)) + } + } + + override suspend fun deleteParticipant(participantId: String) { + roomRepository.deleteParticipant(participantId) + if (isOnline) { + try { firebaseRepository.deleteParticipant(participantId) } + catch (e: Exception) { queueOperation("DELETE_PARTICIPANT", participantId) } + } else { + queueOperation("DELETE_PARTICIPANT", participantId) + } + } + + override suspend fun getParticipantById(participantId: String): Participant? { + if (isOnline) { + try { return firebaseRepository.getParticipantById(participantId) } + catch (_: Exception) {} + } + return roomRepository.getParticipantById(participantId) + } + + override suspend fun findParticipantByName(firstName: String, lastName: String): Participant? { + if (isOnline) { + try { return firebaseRepository.findParticipantByName(firstName, lastName) } + catch (_: Exception) {} + } + return roomRepository.findParticipantByName(firstName, lastName) + } + + override suspend fun updateParticipantTime(eventId: String, participant: ParticipantTime) { + roomRepository.updateParticipantTime(eventId, participant) + if (isOnline) { + try { firebaseRepository.updateParticipantTime(eventId, participant) } + catch (e: Exception) { queueOperation("UPDATE_PARTICIPANT_TIME", participant.uid, parentId = eventId, payload = json.encodeToString(participant)) } + } else { + queueOperation("UPDATE_PARTICIPANT_TIME", participant.uid, parentId = eventId, payload = json.encodeToString(participant)) + } + } + + // --- Recipes --- + + override suspend fun getAllRecipes(): List { + if (isOnline) { + try { + val recipes = firebaseRepository.getAllRecipes() + recipes.filter { it.uid.isNotBlank() } + .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } + .forEach { db.recipeDao().insert(it) } + return recipes + } catch (_: Exception) {} + } + return roomRepository.getAllRecipes() + } + + override suspend fun getUserCreatedRecipes(): List { + if (isOnline) { + try { return firebaseRepository.getUserCreatedRecipes() } + catch (_: Exception) {} + } + return roomRepository.getUserCreatedRecipes() + } + + override suspend fun getRecipeById(recipeId: String): Recipe? { + if (isOnline) { + try { return firebaseRepository.getRecipeById(recipeId) } + catch (_: Exception) {} + } + return roomRepository.getRecipeById(recipeId) + } + + override suspend fun createRecipe(recipe: Recipe) { + roomRepository.createRecipe(recipe) + if (isOnline) { + try { firebaseRepository.createRecipe(recipe) } + catch (e: Exception) { queueOperation("CREATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) } + } else { + queueOperation("CREATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) + } + } + + override suspend fun updateRecipe(recipe: Recipe) { + roomRepository.updateRecipe(recipe) + if (isOnline) { + try { firebaseRepository.updateRecipe(recipe) } + catch (e: Exception) { queueOperation("UPDATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) } + } else { + queueOperation("UPDATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) + } + } + + override suspend fun deleteRecipe(recipeId: String) { + roomRepository.deleteRecipe(recipeId) + if (isOnline) { + try { firebaseRepository.deleteRecipe(recipeId) } + catch (e: Exception) { queueOperation("DELETE_RECIPE", recipeId) } + } else { + queueOperation("DELETE_RECIPE", recipeId) + } + } + + // --- Meals --- + + override suspend fun getAllMealsOfEvent(eventId: String): List { + if (isOnline) { + try { return firebaseRepository.getAllMealsOfEvent(eventId) } + catch (_: Exception) {} + } + return roomRepository.getAllMealsOfEvent(eventId) + } + + override suspend fun getMealById(eventId: String, mealId: String): Meal { + if (isOnline) { + try { return firebaseRepository.getMealById(eventId, mealId) } + catch (_: Exception) {} + } + return roomRepository.getMealById(eventId, mealId) + } + + override suspend fun createNewMeal(eventId: String, day: Instant): Meal { + val meal = roomRepository.createNewMeal(eventId, day) + if (isOnline) { + try { firebaseRepository.updateMeal(eventId, meal) } + catch (e: Exception) { queueOperation("CREATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) } + } else { + queueOperation("CREATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) + } + return meal + } + + override suspend fun deleteMeal(eventId: String, mealId: String) { + roomRepository.deleteMeal(eventId, mealId) + if (isOnline) { + try { firebaseRepository.deleteMeal(eventId, mealId) } + catch (e: Exception) { queueOperation("DELETE_MEAL", mealId, parentId = eventId) } + } else { + queueOperation("DELETE_MEAL", mealId, parentId = eventId) + } + } + + override suspend fun updateMeal(eventId: String, meal: Meal) { + roomRepository.updateMeal(eventId, meal) + if (isOnline) { + try { firebaseRepository.updateMeal(eventId, meal) } + catch (e: Exception) { queueOperation("UPDATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) } + } else { + queueOperation("UPDATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) + } + } + + // --- Ingredients --- + + override suspend fun getIngredientById(ingredientId: String): Ingredient { + if (isOnline) { + try { return firebaseRepository.getIngredientById(ingredientId) } + catch (_: Exception) {} + } + return roomRepository.getIngredientById(ingredientId) + } + + override suspend fun getMealsWithRecipeAndIngredients(eventId: String): List { + if (isOnline) { + try { return firebaseRepository.getMealsWithRecipeAndIngredients(eventId) } + catch (_: Exception) {} + } + return roomRepository.getMealsWithRecipeAndIngredients(eventId) + } + + override suspend fun getAllIngredients(): List { + if (isOnline) { + try { + val ingredients = firebaseRepository.getAllIngredients() + ingredients.filter { it.uid.isNotBlank() }.forEach { db.ingredientDao().insert(it) } + return ingredients + } catch (_: Exception) {} + } + return roomRepository.getAllIngredients() + } + + // --- Shopping Lists --- + + override suspend fun getMultiDayShoppingList(eventId: String): MultiDayShoppingList? { + if (isOnline) { + try { + val list = firebaseRepository.getMultiDayShoppingList(eventId) + if (list != null) db.multiDayShoppingListDao().insert(list) + return list + } catch (_: Exception) {} + } + return roomRepository.getMultiDayShoppingList(eventId) + } + + override suspend fun saveMultiDayShoppingList(eventId: String, multiDayShoppingList: MultiDayShoppingList) { + roomRepository.saveMultiDayShoppingList(eventId, multiDayShoppingList) + if (isOnline) { + try { firebaseRepository.saveMultiDayShoppingList(eventId, multiDayShoppingList) } + catch (e: Exception) { queueOperation("SAVE_MULTI_DAY_SHOPPING_LIST", eventId, parentId = eventId, payload = json.encodeToString(multiDayShoppingList)) } + } else { + queueOperation("SAVE_MULTI_DAY_SHOPPING_LIST", eventId, parentId = eventId, payload = json.encodeToString(multiDayShoppingList)) + } + } + + override suspend fun getDailyShoppingList(eventId: String, date: LocalDate): DailyShoppingList? { + val multiDayList = getMultiDayShoppingList(eventId) + return multiDayList?.dailyLists?.get(date) + } + + override suspend fun saveDailyShoppingList(eventId: String, date: LocalDate, dailyShoppingList: DailyShoppingList) { + val multiDayList = getMultiDayShoppingList(eventId) + if (multiDayList != null) { + val updated = multiDayList.copy(dailyLists = multiDayList.dailyLists.toMutableMap().apply { put(date, dailyShoppingList) }) + saveMultiDayShoppingList(eventId, updated) + } + } + + override suspend fun updateShoppingIngredientStatus(eventId: String, date: LocalDate, ingredientId: String, completed: Boolean) { + val dailyList = getDailyShoppingList(eventId, date) ?: return + val updatedIngredients = dailyList.ingredients.map { ingredient -> + if (ingredient.uid == ingredientId || ingredient.ingredientRef == ingredientId) { + ingredient.apply { shoppingDone = completed } + } else ingredient + } + saveDailyShoppingList(eventId, date, dailyList.copy(ingredients = updatedIngredients)) + } + + override suspend fun deleteShoppingListForDate(eventId: String, date: LocalDate) { + val multiDayList = getMultiDayShoppingList(eventId) ?: return + val updated = multiDayList.copy(dailyLists = multiDayList.dailyLists.toMutableMap().apply { remove(date) }) + saveMultiDayShoppingList(eventId, updated) + } + + // --- Materials --- + + override suspend fun saveMaterialList(eventId: String, materialList: List) { + roomRepository.saveMaterialList(eventId, materialList) + if (isOnline) { + try { firebaseRepository.saveMaterialList(eventId, materialList) } + catch (e: Exception) { queueOperation("SAVE_MATERIAL_LIST", eventId, parentId = eventId, payload = json.encodeToString(materialList)) } + } else { + queueOperation("SAVE_MATERIAL_LIST", eventId, parentId = eventId, payload = json.encodeToString(materialList)) + } + } + + override suspend fun getMaterialListOfEvent(eventId: String): List { + if (isOnline) { + try { return firebaseRepository.getMaterialListOfEvent(eventId) } + catch (_: Exception) {} + } + return roomRepository.getMaterialListOfEvent(eventId) + } + + override suspend fun deleteMaterialById(eventId: String, materialId: String) { + roomRepository.deleteMaterialById(eventId, materialId) + if (isOnline) { + try { firebaseRepository.deleteMaterialById(eventId, materialId) } + catch (e: Exception) { queueOperation("DELETE_MATERIAL", materialId, parentId = eventId) } + } else { + queueOperation("DELETE_MATERIAL", materialId, parentId = eventId) + } + } + + override suspend fun getAllMaterials(): List { + if (isOnline) { + try { return firebaseRepository.getAllMaterials() } + catch (_: Exception) {} + } + return roomRepository.getAllMaterials() + } +} diff --git a/composeApp/src/commonMain/kotlin/data/sync/PendingOperation.kt b/composeApp/src/commonMain/kotlin/data/sync/PendingOperation.kt new file mode 100644 index 00000000..ee0ab499 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/sync/PendingOperation.kt @@ -0,0 +1,14 @@ +package data.sync + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "pending_operations") +data class PendingOperation( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val operationType: String, + val entityId: String, + val parentId: String? = null, + val payloadJson: String, + val timestamp: Long +) diff --git a/composeApp/src/commonMain/kotlin/data/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/data/sync/SyncManager.kt new file mode 100644 index 00000000..dc84016d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/sync/SyncManager.kt @@ -0,0 +1,116 @@ +package data.sync + +import co.touchlab.kermit.Logger +import data.FireBaseRepository +import data.local.AppDatabase +import kotlinx.coroutines.* +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.serialization.json.Json +import model.* + +class SyncManager( + private val db: AppDatabase, + private val firebaseRepository: FireBaseRepository, + private val networkMonitor: NetworkMonitor +) { + private val json = Json { ignoreUnknownKeys = true } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + fun startObserving() { + scope.launch { + networkMonitor.isOnline + .collect { online -> + if (online) { + processPendingOperations() + } + } + } + } + + suspend fun processPendingOperations() { + val pending = db.pendingOperationDao().getAllOrdered() + if (pending.isEmpty()) return + + Logger.i("SyncManager: Processing ${pending.size} pending operations") + + for (op in pending) { + try { + executeOperation(op) + db.pendingOperationDao().deleteById(op.id) + Logger.d("SyncManager: Completed operation ${op.operationType} for ${op.entityId}") + } catch (e: Exception) { + Logger.e("SyncManager: Failed operation ${op.operationType} for ${op.entityId}: ${e.message}") + break + } + } + } + + private suspend fun executeOperation(op: PendingOperation) { + when (op.operationType) { + "CREATE_EVENT", "UPDATE_EVENT" -> { + val event = json.decodeFromString(op.payloadJson) + firebaseRepository.saveExistingEvent(event) + } + "DELETE_EVENT" -> { + firebaseRepository.deleteEvent(op.entityId) + } + "CREATE_PARTICIPANT", "UPDATE_PARTICIPANT" -> { + val participant = json.decodeFromString(op.payloadJson) + firebaseRepository.updateParticipant(participant) + } + "DELETE_PARTICIPANT" -> { + firebaseRepository.deleteParticipant(op.entityId) + } + "CREATE_MEAL", "UPDATE_MEAL" -> { + val meal = json.decodeFromString(op.payloadJson) + val eventId = op.parentId ?: return + firebaseRepository.updateMeal(eventId, meal) + } + "DELETE_MEAL" -> { + val eventId = op.parentId ?: return + firebaseRepository.deleteMeal(eventId, op.entityId) + } + "CREATE_RECIPE", "UPDATE_RECIPE" -> { + val recipe = json.decodeFromString(op.payloadJson) + firebaseRepository.updateRecipe(recipe) + } + "DELETE_RECIPE" -> { + firebaseRepository.deleteRecipe(op.entityId) + } + "ADD_PARTICIPANT_TO_EVENT" -> { + val eventId = op.parentId ?: return + firebaseRepository.updateParticipantTime(eventId, + json.decodeFromString(op.payloadJson)) + } + "DELETE_PARTICIPANT_OF_EVENT" -> { + val eventId = op.parentId ?: return + firebaseRepository.deleteParticipantOfEvent(eventId, op.entityId) + } + "UPDATE_PARTICIPANT_TIME" -> { + val eventId = op.parentId ?: return + firebaseRepository.updateParticipantTime(eventId, + json.decodeFromString(op.payloadJson)) + } + "SAVE_MULTI_DAY_SHOPPING_LIST" -> { + val eventId = op.parentId ?: return + val list = json.decodeFromString(op.payloadJson) + firebaseRepository.saveMultiDayShoppingList(eventId, list) + } + "SAVE_MATERIAL_LIST" -> { + val eventId = op.parentId ?: return + val materials = json.decodeFromString>(op.payloadJson) + firebaseRepository.saveMaterialList(eventId, materials) + } + "DELETE_MATERIAL" -> { + val eventId = op.parentId ?: return + firebaseRepository.deleteMaterialById(eventId, op.entityId) + } + else -> Logger.w("SyncManager: Unknown operation type: ${op.operationType}") + } + } + + fun stop() { + scope.cancel() + } +} diff --git a/composeApp/src/commonMain/kotlin/model/Meal.kt b/composeApp/src/commonMain/kotlin/model/Meal.kt index 236f5591..d39ac0d1 100644 --- a/composeApp/src/commonMain/kotlin/model/Meal.kt +++ b/composeApp/src/commonMain/kotlin/model/Meal.kt @@ -1,13 +1,22 @@ package model import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.PrimaryKey import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import view.shared.list.ListItem @Serializable -@Entity(tableName = "meals") +@Entity( + tableName = "meals", + foreignKeys = [ForeignKey( + entity = Event::class, + parentColumns = ["uid"], + childColumns = ["eventId"], + onDelete = ForeignKey.CASCADE + )] +) data class Meal( @PrimaryKey val uid: String = "", var day: Instant, diff --git a/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt b/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt index 512c2d12..f00c78f6 100644 --- a/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt +++ b/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt @@ -1,6 +1,7 @@ package model import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.PrimaryKey import kotlinx.datetime.Instant @@ -10,7 +11,23 @@ import view.shared.HelperFunctions import view.shared.list.ListItem @Serializable -@Entity(tableName = "participant_times") +@Entity( + tableName = "participant_times", + foreignKeys = [ + ForeignKey( + entity = Event::class, + parentColumns = ["uid"], + childColumns = ["eventId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = Participant::class, + parentColumns = ["uid"], + childColumns = ["participantRef"], + onDelete = ForeignKey.CASCADE + ) + ] +) class ParticipantTime( @PrimaryKey var uid: String = "", var from: Instant, diff --git a/composeApp/src/commonMain/kotlin/model/RecepieSelection.kt b/composeApp/src/commonMain/kotlin/model/RecepieSelection.kt index ef192eb3..a16ab248 100644 --- a/composeApp/src/commonMain/kotlin/model/RecepieSelection.kt +++ b/composeApp/src/commonMain/kotlin/model/RecepieSelection.kt @@ -1,23 +1,18 @@ package model -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.PrimaryKey import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import view.shared.HelperFunctions.Companion.generateRandomStringId @Serializable -@Entity(tableName = "recipe_selections") class RecipeSelection() { - @PrimaryKey var uid: String = generateRandomStringId() + var uid: String = generateRandomStringId() var eaterIds: MutableSet = mutableSetOf() var recipeRef: String = "" var selectedRecipeName: String = "" var guestCount: Int = 0 @Transient - @Ignore var recipe: Recipe? = null } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/model/ShoppingIngredient.kt b/composeApp/src/commonMain/kotlin/model/ShoppingIngredient.kt index 4783a2df..edba6233 100644 --- a/composeApp/src/commonMain/kotlin/model/ShoppingIngredient.kt +++ b/composeApp/src/commonMain/kotlin/model/ShoppingIngredient.kt @@ -1,8 +1,5 @@ package model -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.PrimaryKey import co.touchlab.kermit.Logger import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -13,14 +10,12 @@ import kotlin.math.round import kotlin.math.roundToInt @Serializable -@Entity(tableName = "shopping_ingredients") class ShoppingIngredient() : ListItem { - @PrimaryKey var uid: String = "" + var uid: String = "" var ingredientRef: String = "" var nameEnteredByUser: String = "" @Transient - @Ignore var ingredient: Ingredient? = null var amount: Double = 0.0; var unit: IngredientUnit = IngredientUnit.GRAMM diff --git a/composeApp/src/commonMain/kotlin/modules/DataModules.kt b/composeApp/src/commonMain/kotlin/modules/DataModules.kt index 80fa0f02..c278f171 100644 --- a/composeApp/src/commonMain/kotlin/modules/DataModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/DataModules.kt @@ -2,10 +2,18 @@ package modules import data.DelegatingRepository import data.EventRepository +import data.FireBaseRepository import data.local.AppDatabase +import data.local.RoomRepository import data.local.getDatabaseBuilder +import data.sync.NetworkMonitorImpl +import data.sync.NetworkMonitor +import data.sync.OfflineFirstRepository +import data.sync.SyncManager import kotlinx.coroutines.Dispatchers import org.koin.dsl.module +import services.login.FirebaseLoginAndRegister +import services.login.OfflineLoginAndRegister val dataModules = module { single { @@ -15,7 +23,15 @@ val dataModules = module { .build() } + single { FirebaseLoginAndRegister() } + single { OfflineLoginAndRegister() } + single { FireBaseRepository(get()) } + single { RoomRepository(get(), get()) } + single { NetworkMonitorImpl() } + single { SyncManager(get(), get(), get()) } + single { OfflineFirstRepository(get(), get(), get(), get()) } + single { - DelegatingRepository(get(), get(), get()) + DelegatingRepository(get(), get(), get(), get()) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt b/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt index fbd8b84e..94742165 100644 --- a/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt @@ -12,8 +12,8 @@ import services.event.ParticipantCanEatRecipe import services.update.UpdateChecker val serviceModules = module { - single { DelegatingLoginAndRegister(get()) } - single { SeedDataService(get(), get()) } + single { DelegatingLoginAndRegister(get(), get(), get()) } + single { SeedDataService(get(), get(), get()) } single { CalculateShoppingList(get()) } single { CalculateMaterialList(get()) } single { PdfServiceModule(get(), get()) } diff --git a/composeApp/src/commonMain/kotlin/services/SeedDataService.kt b/composeApp/src/commonMain/kotlin/services/SeedDataService.kt index 12d34850..1afd6b57 100644 --- a/composeApp/src/commonMain/kotlin/services/SeedDataService.kt +++ b/composeApp/src/commonMain/kotlin/services/SeedDataService.kt @@ -4,42 +4,35 @@ import co.touchlab.kermit.Logger import data.AppModePreferences import data.FireBaseRepository import data.local.AppDatabase -import services.login.FirebaseLoginAndRegister class SeedDataService( private val db: AppDatabase, - private val prefs: AppModePreferences + private val prefs: AppModePreferences, + private val firebaseRepo: FireBaseRepository ) { suspend fun downloadSeedDataIfNeeded(): Boolean { if (prefs.isSeedDataDownloaded()) return true return try { - val firebaseRepo = FireBaseRepository(FirebaseLoginAndRegister()) val recipes = firebaseRepo.getAllRecipes() Logger.i("SeedData: Downloaded ${recipes.size} recipes") - recipes.forEach { recipe -> - recipe.shoppingIngredients.forEach { it.ingredient = null } - if (recipe.uid.isNotBlank()) { - db.recipeDao().insert(recipe) - } - } + recipes + .filter { it.uid.isNotBlank() } + .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } + .forEach { db.recipeDao().insert(it) } val ingredients = firebaseRepo.getAllIngredients() Logger.i("SeedData: Downloaded ${ingredients.size} ingredients") - ingredients.forEach { ingredient -> - if (ingredient.uid.isNotBlank()) { - db.ingredientDao().insert(ingredient) - } - } + ingredients + .filter { it.uid.isNotBlank() } + .forEach { db.ingredientDao().insert(it) } val materials = firebaseRepo.getAllMaterials() Logger.i("SeedData: Downloaded ${materials.size} materials") - materials.forEach { material -> - if (material.uid.isNotBlank()) { - db.materialDao().insert(material) - } - } + materials + .filter { it.uid.isNotBlank() } + .forEach { db.materialDao().insert(it) } prefs.setSeedDataDownloaded(true) Logger.i("SeedData: Download complete") diff --git a/composeApp/src/commonMain/kotlin/services/login/DelegatingLoginAndRegister.kt b/composeApp/src/commonMain/kotlin/services/login/DelegatingLoginAndRegister.kt index b0c8807c..50db84da 100644 --- a/composeApp/src/commonMain/kotlin/services/login/DelegatingLoginAndRegister.kt +++ b/composeApp/src/commonMain/kotlin/services/login/DelegatingLoginAndRegister.kt @@ -4,12 +4,11 @@ import data.AppMode import data.AppModeHolder class DelegatingLoginAndRegister( - private val appModeHolder: AppModeHolder + private val appModeHolder: AppModeHolder, + private val firebase: FirebaseLoginAndRegister, + private val offline: OfflineLoginAndRegister ) : LoginAndRegister { - private val firebase by lazy { FirebaseLoginAndRegister() } - private val offline = OfflineLoginAndRegister() - private val active: LoginAndRegister get() = when (appModeHolder.mode.value) { AppMode.OFFLINE_ONLY -> offline diff --git a/composeApp/src/commonMain/kotlin/view/login/Login.kt b/composeApp/src/commonMain/kotlin/view/login/Login.kt index 13f7c0b1..7ea47d8a 100644 --- a/composeApp/src/commonMain/kotlin/view/login/Login.kt +++ b/composeApp/src/commonMain/kotlin/view/login/Login.kt @@ -201,7 +201,7 @@ fun LoginContent( Text(text = "App offline nutzen") } Text( - text = "Daten werden nur lokal gespeichert. Beim ersten Start werden alle Rezepte und Zutaten einmalig heruntergeladen.", + text = "Daten werden nur lokal gespeichert. Beim ersten Start werden alle Rezepte und Zutaten einmalig heruntergeladen (Internetverbindung erforderlich).", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp) diff --git a/composeApp/src/desktopMain/kotlin/data/sync/NetworkMonitorImpl.desktop.kt b/composeApp/src/desktopMain/kotlin/data/sync/NetworkMonitorImpl.desktop.kt new file mode 100644 index 00000000..61d84616 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/data/sync/NetworkMonitorImpl.desktop.kt @@ -0,0 +1,35 @@ +package data.sync + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.net.InetSocketAddress +import java.net.Socket + +actual class NetworkMonitorImpl actual constructor() : NetworkMonitor { + private val _isOnline = MutableStateFlow(true) + override val isOnline: StateFlow = _isOnline.asStateFlow() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + scope.launch { + while (isActive) { + _isOnline.value = checkConnectivity() + delay(10_000) + } + } + } + + private fun checkConnectivity(): Boolean { + return try { + Socket().use { socket -> + socket.connect(InetSocketAddress("8.8.8.8", 53), 3000) + true + } + } catch (_: Exception) { + false + } + } +} diff --git a/composeApp/src/iosMain/kotlin/data/sync/NetworkMonitorImpl.ios.kt b/composeApp/src/iosMain/kotlin/data/sync/NetworkMonitorImpl.ios.kt new file mode 100644 index 00000000..b043e344 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/data/sync/NetworkMonitorImpl.ios.kt @@ -0,0 +1,26 @@ +package data.sync + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.Network.nw_path_get_status +import platform.Network.nw_path_monitor_create +import platform.Network.nw_path_monitor_set_update_handler +import platform.Network.nw_path_monitor_start +import platform.Network.nw_path_monitor_set_queue +import platform.Network.nw_path_status_satisfied +import platform.darwin.dispatch_get_main_queue + +actual class NetworkMonitorImpl actual constructor() : NetworkMonitor { + private val _isOnline = MutableStateFlow(true) + override val isOnline: StateFlow = _isOnline.asStateFlow() + + init { + val monitor = nw_path_monitor_create() + nw_path_monitor_set_queue(monitor, dispatch_get_main_queue()) + nw_path_monitor_set_update_handler(monitor) { path -> + _isOnline.value = nw_path_get_status(path) == nw_path_status_satisfied + } + nw_path_monitor_start(monitor) + } +} From aabe90b752670aca4fe910cc21534d73358469e3 Mon Sep 17 00:00:00 2001 From: Frederik Kischewski Date: Mon, 4 May 2026 20:31:16 +0200 Subject: [PATCH 3/9] fix: add @ConstructedBy for Room KMP iOS support, add FK indices - Add AppDatabaseConstructor (expect object) required by Room KMP for non-Android platforms - Add Index on foreign key columns (eventId, participantRef) to prevent full table scans - Remove fallbackToDestructiveMigration to preserve data across restarts --- .../schemas/data.local.AppDatabase/1.json | 228 +++++++++--------- .../kotlin/data/local/AppDatabase.kt | 7 +- .../src/commonMain/kotlin/model/Meal.kt | 4 +- .../kotlin/model/ParticipantTime.kt | 4 +- .../commonMain/kotlin/modules/DataModules.kt | 1 - 5 files changed, 130 insertions(+), 114 deletions(-) diff --git a/composeApp/schemas/data.local.AppDatabase/1.json b/composeApp/schemas/data.local.AppDatabase/1.json index f29f136e..5a342d29 100644 --- a/composeApp/schemas/data.local.AppDatabase/1.json +++ b/composeApp/schemas/data.local.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "4527db3fc1481c0cd69957e92d2c2f8f", + "identityHash": "a349f4d52240b32de9df9cc39e775a74", "entities": [ { "tableName": "events", @@ -313,7 +313,7 @@ }, { "tableName": "meals", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `day` INTEGER NOT NULL, `mealType` TEXT NOT NULL, `recipeSelections` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `day` INTEGER NOT NULL, `mealType` TEXT NOT NULL, `recipeSelections` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "uid", @@ -351,118 +351,35 @@ "columnNames": [ "uid" ] - } - }, - { - "tableName": "shopping_ingredients", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `ingredientRef` TEXT NOT NULL, `nameEnteredByUser` TEXT NOT NULL, `amount` REAL NOT NULL, `unit` TEXT NOT NULL, `title` TEXT, `shoppingDone` INTEGER NOT NULL, `note` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ingredientRef", - "columnName": "ingredientRef", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "nameEnteredByUser", - "columnName": "nameEnteredByUser", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "amount", - "columnName": "amount", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "unit", - "columnName": "unit", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT" - }, - { - "fieldPath": "shoppingDone", - "columnName": "shoppingDone", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "note", - "columnName": "note", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "source", - "columnName": "source", - "affinity": "TEXT", - "notNull": true + }, + "indices": [ + { + "name": "index_meals_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meals_eventId` ON `${TABLE_NAME}` (`eventId`)" } ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "recipe_selections", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `eaterIds` TEXT NOT NULL, `recipeRef` TEXT NOT NULL, `selectedRecipeName` TEXT NOT NULL, `guestCount` INTEGER NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "eaterIds", - "columnName": "eaterIds", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "recipeRef", - "columnName": "recipeRef", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "selectedRecipeName", - "columnName": "selectedRecipeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "guestCount", - "columnName": "guestCount", - "affinity": "INTEGER", - "notNull": true + "foreignKeys": [ + { + "table": "events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "uid" + ] } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } + ] }, { "tableName": "participant_times", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `participantRef` TEXT NOT NULL, `cookingGroup` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `participantRef` TEXT NOT NULL, `cookingGroup` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`participantRef`) REFERENCES `participants`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "uid", @@ -506,7 +423,51 @@ "columnNames": [ "uid" ] - } + }, + "indices": [ + { + "name": "index_participant_times_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_participant_times_eventId` ON `${TABLE_NAME}` (`eventId`)" + }, + { + "name": "index_participant_times_participantRef", + "unique": false, + "columnNames": [ + "participantRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_participant_times_participantRef` ON `${TABLE_NAME}` (`participantRef`)" + } + ], + "foreignKeys": [ + { + "table": "events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "participants", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantRef" + ], + "referencedColumns": [ + "uid" + ] + } + ] }, { "tableName": "materials", @@ -567,11 +528,58 @@ "eventId" ] } + }, + { + "tableName": "pending_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `operationType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `parentId` TEXT, `payloadJson` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "operationType", + "columnName": "operationType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4527db3fc1481c0cd69957e92d2c2f8f')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a349f4d52240b32de9df9cc39e775a74')" ] } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt b/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt index 5b0f4749..6e53df3b 100644 --- a/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt +++ b/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt @@ -1,12 +1,16 @@ package data.local +import androidx.room.ConstructedBy import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import data.local.dao.* import data.sync.PendingOperation import model.* +expect object AppDatabaseConstructor : RoomDatabaseConstructor + @Database( entities = [ Event::class, @@ -19,10 +23,11 @@ import model.* MultiDayShoppingList::class, PendingOperation::class, ], - version = 3, + version = 1, exportSchema = true ) @TypeConverters(Converters::class) +@ConstructedBy(AppDatabaseConstructor::class) abstract class AppDatabase : RoomDatabase() { abstract fun eventDao(): EventDao abstract fun participantDao(): ParticipantDao diff --git a/composeApp/src/commonMain/kotlin/model/Meal.kt b/composeApp/src/commonMain/kotlin/model/Meal.kt index d39ac0d1..c6e6c024 100644 --- a/composeApp/src/commonMain/kotlin/model/Meal.kt +++ b/composeApp/src/commonMain/kotlin/model/Meal.kt @@ -2,6 +2,7 @@ package model import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Index import androidx.room.PrimaryKey import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -15,7 +16,8 @@ import view.shared.list.ListItem parentColumns = ["uid"], childColumns = ["eventId"], onDelete = ForeignKey.CASCADE - )] + )], + indices = [Index("eventId")] ) data class Meal( @PrimaryKey val uid: String = "", diff --git a/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt b/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt index f00c78f6..fbd4fe6e 100644 --- a/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt +++ b/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt @@ -3,6 +3,7 @@ package model import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore +import androidx.room.Index import androidx.room.PrimaryKey import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -26,7 +27,8 @@ import view.shared.list.ListItem childColumns = ["participantRef"], onDelete = ForeignKey.CASCADE ) - ] + ], + indices = [Index("eventId"), Index("participantRef")] ) class ParticipantTime( @PrimaryKey var uid: String = "", diff --git a/composeApp/src/commonMain/kotlin/modules/DataModules.kt b/composeApp/src/commonMain/kotlin/modules/DataModules.kt index c278f171..fb8a92f2 100644 --- a/composeApp/src/commonMain/kotlin/modules/DataModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/DataModules.kt @@ -19,7 +19,6 @@ val dataModules = module { single { getDatabaseBuilder() .setQueryCoroutineContext(Dispatchers.IO) - .fallbackToDestructiveMigration(true) .build() } From 67c1a2fcd69afb829ec963fe0a22c5ceefd72977 Mon Sep 17 00:00:00 2001 From: Frederik Kischewski Date: Mon, 4 May 2026 20:33:50 +0200 Subject: [PATCH 4/9] fix: add explicit Dispatchers.IO import for iOS compatibility --- composeApp/src/commonMain/kotlin/modules/DataModules.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/composeApp/src/commonMain/kotlin/modules/DataModules.kt b/composeApp/src/commonMain/kotlin/modules/DataModules.kt index fb8a92f2..0d8f7722 100644 --- a/composeApp/src/commonMain/kotlin/modules/DataModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/DataModules.kt @@ -11,6 +11,7 @@ import data.sync.NetworkMonitor import data.sync.OfflineFirstRepository import data.sync.SyncManager import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import org.koin.dsl.module import services.login.FirebaseLoginAndRegister import services.login.OfflineLoginAndRegister From 3377429f2508858b7c89cc345f3234d0a95b02c0 Mon Sep 17 00:00:00 2001 From: Frederik Kischewski Date: Mon, 4 May 2026 21:50:10 +0200 Subject: [PATCH 5/9] feat: add full Firebase-to-Room sync and read caching - Add read caching to all OfflineFirstRepository methods so Room stays in sync with Firebase for offline fallback - Add InitialSyncService for full data sync on app start (events, participants, meals, recipes, ingredients, materials, shopping lists) - Extract writeToFirebaseOrQueue helper to reduce duplication - Use OFFLINE_FIRST for all authenticated users (ONLINE delegates to OfflineFirstRepository too) for automatic offline fallback - Switch mode back to ONLINE on logout so login uses Firebase auth --- composeApp/src/commonMain/kotlin/App.kt | 5 +- .../kotlin/data/DelegatingRepository.kt | 3 +- .../kotlin/data/sync/InitialSyncService.kt | 74 +++++ .../data/sync/OfflineFirstRepository.kt | 263 ++++++++++-------- .../commonMain/kotlin/modules/DataModules.kt | 8 +- .../view/event/homescreen/DrawerContent.kt | 5 +- 6 files changed, 235 insertions(+), 123 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index adbdb935..eea0e56c 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.remember import data.AppMode import data.AppModeHolder import data.AppModePreferences +import data.sync.InitialSyncService import data.sync.SyncManager import modules.dataModules import modules.serviceModules @@ -38,9 +39,11 @@ fun App(pdfService: PdfServiceImpl) { pdfServiceModule.setPdfService(pdfService) val syncManager = koinInject() + val initialSyncService = koinInject() LaunchedEffect(Unit) { - if (appModeHolder.mode.value == AppMode.OFFLINE_FIRST) { + if (appModeHolder.mode.value != AppMode.OFFLINE_ONLY) { syncManager.startObserving() + initialSyncService.syncAllUserData() } } diff --git a/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt b/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt index b5186b35..8fa3d584 100644 --- a/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt +++ b/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt @@ -16,9 +16,8 @@ class DelegatingRepository( private val active: EventRepository get() = when (appModeHolder.mode.value) { - AppMode.ONLINE -> firebaseRepository AppMode.OFFLINE_ONLY -> roomRepository - AppMode.OFFLINE_FIRST -> offlineFirstRepository + AppMode.ONLINE, AppMode.OFFLINE_FIRST -> offlineFirstRepository } override suspend fun deleteEvent(eventId: String) = active.deleteEvent(eventId) diff --git a/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt b/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt new file mode 100644 index 00000000..d887f703 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt @@ -0,0 +1,74 @@ +package data.sync + +import co.touchlab.kermit.Logger +import data.FireBaseRepository +import data.local.AppDatabase +import kotlinx.coroutines.flow.first +import services.login.FirebaseLoginAndRegister + +class InitialSyncService( + private val db: AppDatabase, + private val firebaseRepository: FireBaseRepository, + private val loginAndRegister: FirebaseLoginAndRegister +) { + suspend fun syncAllUserData() { + if (!loginAndRegister.isAuthenticated()) return + + try { + val group = loginAndRegister.getCustomUserGroup() + Logger.i("InitialSync: Starting full sync for group $group") + + val recipes = firebaseRepository.getAllRecipes() + recipes + .filter { it.uid.isNotBlank() } + .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } + .forEach { db.recipeDao().insert(it) } + Logger.i("InitialSync: Synced ${recipes.size} recipes") + + val ingredients = firebaseRepository.getAllIngredients() + ingredients.filter { it.uid.isNotBlank() }.forEach { db.ingredientDao().insert(it) } + Logger.i("InitialSync: Synced ${ingredients.size} ingredients") + + val materials = firebaseRepository.getAllMaterials() + materials.filter { it.uid.isNotBlank() }.forEach { db.materialDao().insert(it) } + Logger.i("InitialSync: Synced ${materials.size} materials") + + val events = firebaseRepository.getEventList(group).first() + events.forEach { db.eventDao().insert(it) } + Logger.i("InitialSync: Synced ${events.size} events") + + events.forEach { event -> + try { + val participantTimes = firebaseRepository.getParticipantsOfEvent(event.uid, true) + participantTimes.forEach { pt -> + pt.eventId = event.uid + db.participantTimeDao().insert(pt) + if (pt.participant != null) { + db.participantDao().insert(pt.participant!!) + } + } + + val meals = firebaseRepository.getAllMealsOfEvent(event.uid) + meals.forEach { meal -> + meal.eventId = event.uid + db.mealDao().insert(meal) + } + + val shoppingList = firebaseRepository.getMultiDayShoppingList(event.uid) + if (shoppingList != null) { + db.multiDayShoppingListDao().insert(shoppingList) + } + + val eventMaterials = firebaseRepository.getMaterialListOfEvent(event.uid) + eventMaterials.forEach { db.materialDao().insert(it) } + } catch (e: Exception) { + Logger.e("InitialSync: Error syncing event ${event.uid}: ${e.message}") + } + } + + Logger.i("InitialSync: Full sync complete") + } catch (e: Exception) { + Logger.e("InitialSync: Sync failed: ${e.message}", e) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt b/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt index a851f013..6e08dfbb 100644 --- a/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt +++ b/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt @@ -7,6 +7,7 @@ import data.local.AppDatabase import data.local.RoomRepository import kotlin.time.Clock import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.serialization.encodeToString @@ -41,15 +42,27 @@ class OfflineFirstRepository( ) } + private suspend fun writeToFirebaseOrQueue( + operationType: String, + entityId: String, + parentId: String? = null, + payload: String = "", + firebaseAction: suspend () -> Unit + ) { + if (isOnline) { + try { firebaseAction() } + catch (e: Exception) { queueOperation(operationType, entityId, parentId, payload) } + } else { + queueOperation(operationType, entityId, parentId, payload) + } + } + // --- Events --- override suspend fun deleteEvent(eventId: String) { roomRepository.deleteEvent(eventId) - if (isOnline) { - try { firebaseRepository.deleteEvent(eventId) } - catch (e: Exception) { queueOperation("DELETE_EVENT", eventId) } - } else { - queueOperation("DELETE_EVENT", eventId) + writeToFirebaseOrQueue("DELETE_EVENT", eventId) { + firebaseRepository.deleteEvent(eventId) } } @@ -66,29 +79,27 @@ class OfflineFirstRepository( override suspend fun createNewEvent(): Event { val event = roomRepository.createNewEvent() - if (isOnline) { - try { firebaseRepository.saveExistingEvent(event) } - catch (e: Exception) { queueOperation("CREATE_EVENT", event.uid, payload = json.encodeToString(event)) } - } else { - queueOperation("CREATE_EVENT", event.uid, payload = json.encodeToString(event)) + writeToFirebaseOrQueue("CREATE_EVENT", event.uid, payload = json.encodeToString(event)) { + firebaseRepository.saveExistingEvent(event) } return event } override suspend fun saveExistingEvent(event: Event) { roomRepository.saveExistingEvent(event) - if (isOnline) { - try { firebaseRepository.saveExistingEvent(event) } - catch (e: Exception) { queueOperation("UPDATE_EVENT", event.uid, payload = json.encodeToString(event)) } - } else { - queueOperation("UPDATE_EVENT", event.uid, payload = json.encodeToString(event)) + writeToFirebaseOrQueue("UPDATE_EVENT", event.uid, payload = json.encodeToString(event)) { + firebaseRepository.saveExistingEvent(event) } } override suspend fun getEventList(group: String): Flow> { if (isOnline) { try { - return firebaseRepository.getEventList(group) + return firebaseRepository.getEventList(group).onEach { events -> + events.forEach { event -> + db.eventDao().insert(event) + } + } } catch (_: Exception) {} } return roomRepository.getEventList(group) @@ -106,76 +117,76 @@ class OfflineFirstRepository( override suspend fun getParticipantsOfEvent(eventId: String, withParticipant: Boolean): List { if (isOnline) { - try { return firebaseRepository.getParticipantsOfEvent(eventId, withParticipant) } - catch (_: Exception) {} + try { + val participantTimes = firebaseRepository.getParticipantsOfEvent(eventId, withParticipant) + participantTimes.forEach { pt -> + pt.eventId = eventId + db.participantTimeDao().insert(pt) + if (pt.participant != null) { + db.participantDao().insert(pt.participant!!) + } + } + return participantTimes + } catch (_: Exception) {} } return roomRepository.getParticipantsOfEvent(eventId, withParticipant) } override suspend fun getAllParticipantsOfStamm(): Flow> { if (isOnline) { - try { return firebaseRepository.getAllParticipantsOfStamm() } - catch (_: Exception) {} + try { + return firebaseRepository.getAllParticipantsOfStamm().onEach { participants -> + participants.forEach { db.participantDao().insert(it) } + } + } catch (_: Exception) {} } return roomRepository.getAllParticipantsOfStamm() } override suspend fun deleteParticipantOfEvent(eventId: String, participantId: String) { roomRepository.deleteParticipantOfEvent(eventId, participantId) - if (isOnline) { - try { firebaseRepository.deleteParticipantOfEvent(eventId, participantId) } - catch (e: Exception) { queueOperation("DELETE_PARTICIPANT_OF_EVENT", participantId, parentId = eventId) } - } else { - queueOperation("DELETE_PARTICIPANT_OF_EVENT", participantId, parentId = eventId) + writeToFirebaseOrQueue("DELETE_PARTICIPANT_OF_EVENT", participantId, parentId = eventId) { + firebaseRepository.deleteParticipantOfEvent(eventId, participantId) } } override suspend fun addParticipantToEvent(newParticipant: Participant, event: Event): ParticipantTime { val pt = roomRepository.addParticipantToEvent(newParticipant, event) - if (isOnline) { - try { firebaseRepository.addParticipantToEvent(newParticipant, event) } - catch (e: Exception) { queueOperation("ADD_PARTICIPANT_TO_EVENT", pt.uid, parentId = event.uid, payload = json.encodeToString(pt)) } - } else { - queueOperation("ADD_PARTICIPANT_TO_EVENT", pt.uid, parentId = event.uid, payload = json.encodeToString(pt)) + writeToFirebaseOrQueue("ADD_PARTICIPANT_TO_EVENT", pt.uid, parentId = event.uid, payload = json.encodeToString(pt)) { + firebaseRepository.addParticipantToEvent(newParticipant, event) } return pt } override suspend fun createNewParticipant(participant: Participant): Participant? { val result = roomRepository.createNewParticipant(participant) ?: return null - if (isOnline) { - try { firebaseRepository.createNewParticipant(result) } - catch (e: Exception) { queueOperation("CREATE_PARTICIPANT", result.uid, payload = json.encodeToString(result)) } - } else { - queueOperation("CREATE_PARTICIPANT", result.uid, payload = json.encodeToString(result)) + writeToFirebaseOrQueue("CREATE_PARTICIPANT", result.uid, payload = json.encodeToString(result)) { + firebaseRepository.createNewParticipant(result) } return result } override suspend fun updateParticipant(participant: Participant) { roomRepository.updateParticipant(participant) - if (isOnline) { - try { firebaseRepository.updateParticipant(participant) } - catch (e: Exception) { queueOperation("UPDATE_PARTICIPANT", participant.uid, payload = json.encodeToString(participant)) } - } else { - queueOperation("UPDATE_PARTICIPANT", participant.uid, payload = json.encodeToString(participant)) + writeToFirebaseOrQueue("UPDATE_PARTICIPANT", participant.uid, payload = json.encodeToString(participant)) { + firebaseRepository.updateParticipant(participant) } } override suspend fun deleteParticipant(participantId: String) { roomRepository.deleteParticipant(participantId) - if (isOnline) { - try { firebaseRepository.deleteParticipant(participantId) } - catch (e: Exception) { queueOperation("DELETE_PARTICIPANT", participantId) } - } else { - queueOperation("DELETE_PARTICIPANT", participantId) + writeToFirebaseOrQueue("DELETE_PARTICIPANT", participantId) { + firebaseRepository.deleteParticipant(participantId) } } override suspend fun getParticipantById(participantId: String): Participant? { if (isOnline) { - try { return firebaseRepository.getParticipantById(participantId) } - catch (_: Exception) {} + try { + val participant = firebaseRepository.getParticipantById(participantId) + if (participant != null) db.participantDao().insert(participant) + return participant + } catch (_: Exception) {} } return roomRepository.getParticipantById(participantId) } @@ -190,11 +201,8 @@ class OfflineFirstRepository( override suspend fun updateParticipantTime(eventId: String, participant: ParticipantTime) { roomRepository.updateParticipantTime(eventId, participant) - if (isOnline) { - try { firebaseRepository.updateParticipantTime(eventId, participant) } - catch (e: Exception) { queueOperation("UPDATE_PARTICIPANT_TIME", participant.uid, parentId = eventId, payload = json.encodeToString(participant)) } - } else { - queueOperation("UPDATE_PARTICIPANT_TIME", participant.uid, parentId = eventId, payload = json.encodeToString(participant)) + writeToFirebaseOrQueue("UPDATE_PARTICIPANT_TIME", participant.uid, parentId = eventId, payload = json.encodeToString(participant)) { + firebaseRepository.updateParticipantTime(eventId, participant) } } @@ -204,7 +212,8 @@ class OfflineFirstRepository( if (isOnline) { try { val recipes = firebaseRepository.getAllRecipes() - recipes.filter { it.uid.isNotBlank() } + recipes + .filter { it.uid.isNotBlank() } .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } .forEach { db.recipeDao().insert(it) } return recipes @@ -215,47 +224,66 @@ class OfflineFirstRepository( override suspend fun getUserCreatedRecipes(): List { if (isOnline) { - try { return firebaseRepository.getUserCreatedRecipes() } - catch (_: Exception) {} + try { + val recipes = firebaseRepository.getUserCreatedRecipes() + recipes + .filter { it.uid.isNotBlank() } + .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } + .forEach { db.recipeDao().insert(it) } + return recipes + } catch (_: Exception) {} } return roomRepository.getUserCreatedRecipes() } override suspend fun getRecipeById(recipeId: String): Recipe? { if (isOnline) { - try { return firebaseRepository.getRecipeById(recipeId) } - catch (_: Exception) {} + try { + val recipe = firebaseRepository.getRecipeById(recipeId) + if (recipe != null && recipe.uid.isNotBlank()) { + val toCache = Recipe().apply { + uid = recipe.uid; name = recipe.name; description = recipe.description + cookingInstructions = recipe.cookingInstructions; notes = recipe.notes + dietaryHabit = recipe.dietaryHabit; materials = recipe.materials + pageInCookbook = recipe.pageInCookbook; price = recipe.price + season = recipe.season; foodIntolerances = recipe.foodIntolerances + source = recipe.source; time = recipe.time; skillLevel = recipe.skillLevel + type = recipe.type + shoppingIngredients = recipe.shoppingIngredients.map { si -> + ShoppingIngredient().apply { + uid = si.uid; ingredientRef = si.ingredientRef + nameEnteredByUser = si.nameEnteredByUser; amount = si.amount + unit = si.unit; title = si.title; shoppingDone = si.shoppingDone + note = si.note; source = si.source + } + } + } + db.recipeDao().insert(toCache) + } + return recipe + } catch (_: Exception) {} } return roomRepository.getRecipeById(recipeId) } override suspend fun createRecipe(recipe: Recipe) { roomRepository.createRecipe(recipe) - if (isOnline) { - try { firebaseRepository.createRecipe(recipe) } - catch (e: Exception) { queueOperation("CREATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) } - } else { - queueOperation("CREATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) + writeToFirebaseOrQueue("CREATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) { + firebaseRepository.createRecipe(recipe) } } override suspend fun updateRecipe(recipe: Recipe) { roomRepository.updateRecipe(recipe) - if (isOnline) { - try { firebaseRepository.updateRecipe(recipe) } - catch (e: Exception) { queueOperation("UPDATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) } - } else { - queueOperation("UPDATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) + writeToFirebaseOrQueue("UPDATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) { + firebaseRepository.updateRecipe(recipe) } } override suspend fun deleteRecipe(recipeId: String) { roomRepository.deleteRecipe(recipeId) - if (isOnline) { - try { firebaseRepository.deleteRecipe(recipeId) } - catch (e: Exception) { queueOperation("DELETE_RECIPE", recipeId) } - } else { - queueOperation("DELETE_RECIPE", recipeId) + writeToFirebaseOrQueue("DELETE_RECIPE", recipeId) { + firebaseRepository.deleteRecipe(recipeId) } } @@ -263,48 +291,49 @@ class OfflineFirstRepository( override suspend fun getAllMealsOfEvent(eventId: String): List { if (isOnline) { - try { return firebaseRepository.getAllMealsOfEvent(eventId) } - catch (_: Exception) {} + try { + val meals = firebaseRepository.getAllMealsOfEvent(eventId) + meals.forEach { meal -> + meal.eventId = eventId + db.mealDao().insert(meal) + } + return meals + } catch (_: Exception) {} } return roomRepository.getAllMealsOfEvent(eventId) } override suspend fun getMealById(eventId: String, mealId: String): Meal { if (isOnline) { - try { return firebaseRepository.getMealById(eventId, mealId) } - catch (_: Exception) {} + try { + val meal = firebaseRepository.getMealById(eventId, mealId) + meal.eventId = eventId + db.mealDao().insert(meal) + return meal + } catch (_: Exception) {} } return roomRepository.getMealById(eventId, mealId) } override suspend fun createNewMeal(eventId: String, day: Instant): Meal { val meal = roomRepository.createNewMeal(eventId, day) - if (isOnline) { - try { firebaseRepository.updateMeal(eventId, meal) } - catch (e: Exception) { queueOperation("CREATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) } - } else { - queueOperation("CREATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) + writeToFirebaseOrQueue("CREATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) { + firebaseRepository.updateMeal(eventId, meal) } return meal } override suspend fun deleteMeal(eventId: String, mealId: String) { roomRepository.deleteMeal(eventId, mealId) - if (isOnline) { - try { firebaseRepository.deleteMeal(eventId, mealId) } - catch (e: Exception) { queueOperation("DELETE_MEAL", mealId, parentId = eventId) } - } else { - queueOperation("DELETE_MEAL", mealId, parentId = eventId) + writeToFirebaseOrQueue("DELETE_MEAL", mealId, parentId = eventId) { + firebaseRepository.deleteMeal(eventId, mealId) } } override suspend fun updateMeal(eventId: String, meal: Meal) { roomRepository.updateMeal(eventId, meal) - if (isOnline) { - try { firebaseRepository.updateMeal(eventId, meal) } - catch (e: Exception) { queueOperation("UPDATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) } - } else { - queueOperation("UPDATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) + writeToFirebaseOrQueue("UPDATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) { + firebaseRepository.updateMeal(eventId, meal) } } @@ -312,16 +341,25 @@ class OfflineFirstRepository( override suspend fun getIngredientById(ingredientId: String): Ingredient { if (isOnline) { - try { return firebaseRepository.getIngredientById(ingredientId) } - catch (_: Exception) {} + try { + val ingredient = firebaseRepository.getIngredientById(ingredientId) + db.ingredientDao().insert(ingredient) + return ingredient + } catch (_: Exception) {} } return roomRepository.getIngredientById(ingredientId) } override suspend fun getMealsWithRecipeAndIngredients(eventId: String): List { if (isOnline) { - try { return firebaseRepository.getMealsWithRecipeAndIngredients(eventId) } - catch (_: Exception) {} + try { + val meals = firebaseRepository.getMealsWithRecipeAndIngredients(eventId) + meals.forEach { meal -> + meal.eventId = eventId + db.mealDao().insert(meal) + } + return meals + } catch (_: Exception) {} } return roomRepository.getMealsWithRecipeAndIngredients(eventId) } @@ -352,11 +390,8 @@ class OfflineFirstRepository( override suspend fun saveMultiDayShoppingList(eventId: String, multiDayShoppingList: MultiDayShoppingList) { roomRepository.saveMultiDayShoppingList(eventId, multiDayShoppingList) - if (isOnline) { - try { firebaseRepository.saveMultiDayShoppingList(eventId, multiDayShoppingList) } - catch (e: Exception) { queueOperation("SAVE_MULTI_DAY_SHOPPING_LIST", eventId, parentId = eventId, payload = json.encodeToString(multiDayShoppingList)) } - } else { - queueOperation("SAVE_MULTI_DAY_SHOPPING_LIST", eventId, parentId = eventId, payload = json.encodeToString(multiDayShoppingList)) + writeToFirebaseOrQueue("SAVE_MULTI_DAY_SHOPPING_LIST", eventId, parentId = eventId, payload = json.encodeToString(multiDayShoppingList)) { + firebaseRepository.saveMultiDayShoppingList(eventId, multiDayShoppingList) } } @@ -393,36 +428,36 @@ class OfflineFirstRepository( override suspend fun saveMaterialList(eventId: String, materialList: List) { roomRepository.saveMaterialList(eventId, materialList) - if (isOnline) { - try { firebaseRepository.saveMaterialList(eventId, materialList) } - catch (e: Exception) { queueOperation("SAVE_MATERIAL_LIST", eventId, parentId = eventId, payload = json.encodeToString(materialList)) } - } else { - queueOperation("SAVE_MATERIAL_LIST", eventId, parentId = eventId, payload = json.encodeToString(materialList)) + writeToFirebaseOrQueue("SAVE_MATERIAL_LIST", eventId, parentId = eventId, payload = json.encodeToString(materialList)) { + firebaseRepository.saveMaterialList(eventId, materialList) } } override suspend fun getMaterialListOfEvent(eventId: String): List { if (isOnline) { - try { return firebaseRepository.getMaterialListOfEvent(eventId) } - catch (_: Exception) {} + try { + val materials = firebaseRepository.getMaterialListOfEvent(eventId) + materials.forEach { db.materialDao().insert(it) } + return materials + } catch (_: Exception) {} } return roomRepository.getMaterialListOfEvent(eventId) } override suspend fun deleteMaterialById(eventId: String, materialId: String) { roomRepository.deleteMaterialById(eventId, materialId) - if (isOnline) { - try { firebaseRepository.deleteMaterialById(eventId, materialId) } - catch (e: Exception) { queueOperation("DELETE_MATERIAL", materialId, parentId = eventId) } - } else { - queueOperation("DELETE_MATERIAL", materialId, parentId = eventId) + writeToFirebaseOrQueue("DELETE_MATERIAL", materialId, parentId = eventId) { + firebaseRepository.deleteMaterialById(eventId, materialId) } } override suspend fun getAllMaterials(): List { if (isOnline) { - try { return firebaseRepository.getAllMaterials() } - catch (_: Exception) {} + try { + val materials = firebaseRepository.getAllMaterials() + materials.filter { it.uid.isNotBlank() }.forEach { db.materialDao().insert(it) } + return materials + } catch (_: Exception) {} } return roomRepository.getAllMaterials() } diff --git a/composeApp/src/commonMain/kotlin/modules/DataModules.kt b/composeApp/src/commonMain/kotlin/modules/DataModules.kt index 0d8f7722..a52a1766 100644 --- a/composeApp/src/commonMain/kotlin/modules/DataModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/DataModules.kt @@ -8,19 +8,16 @@ import data.local.RoomRepository import data.local.getDatabaseBuilder import data.sync.NetworkMonitorImpl import data.sync.NetworkMonitor +import data.sync.InitialSyncService import data.sync.OfflineFirstRepository import data.sync.SyncManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO import org.koin.dsl.module import services.login.FirebaseLoginAndRegister import services.login.OfflineLoginAndRegister val dataModules = module { single { - getDatabaseBuilder() - .setQueryCoroutineContext(Dispatchers.IO) - .build() + getDatabaseBuilder().build() } single { FirebaseLoginAndRegister() } @@ -29,6 +26,7 @@ val dataModules = module { single { RoomRepository(get(), get()) } single { NetworkMonitorImpl() } single { SyncManager(get(), get(), get()) } + single { InitialSyncService(get(), get(), get()) } single { OfflineFirstRepository(get(), get(), get(), get()) } single { diff --git a/composeApp/src/commonMain/kotlin/view/event/homescreen/DrawerContent.kt b/composeApp/src/commonMain/kotlin/view/event/homescreen/DrawerContent.kt index c672cf72..2021b0cf 100644 --- a/composeApp/src/commonMain/kotlin/view/event/homescreen/DrawerContent.kt +++ b/composeApp/src/commonMain/kotlin/view/event/homescreen/DrawerContent.kt @@ -19,7 +19,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import data.EventRepository +import data.AppMode +import data.AppModeHolder import kotlinx.coroutines.launch import org.koin.compose.koinInject import services.login.LoginAndRegister @@ -33,6 +34,7 @@ fun DrawerContent( onViewRecipes: () -> Unit ) { val login: LoginAndRegister = koinInject() + val appModeHolder: AppModeHolder = koinInject() var showConfirmDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -72,6 +74,7 @@ fun DrawerContent( selected = false, onClick = { scope.launch { + appModeHolder.switchMode(AppMode.ONLINE) login.logout() onLogoutNavigation() } From 5a2b60e7185e6d4890ab565214097a6cb605b3a1 Mon Sep 17 00:00:00 2001 From: Frederik Kischewski Date: Thu, 7 May 2026 21:09:52 +0200 Subject: [PATCH 6/9] refactor: offline-first architecture overhaul and PR review fixes - Replace @Insert(REPLACE) with @Upsert in BaseDao to prevent CASCADE deletes when syncing events from Firebase - Remove AppMode.ONLINE, simplify to OFFLINE_ONLY and OFFLINE_FIRST - Merge SeedDataService into InitialSyncService with syncBaseData() - Move Koin module definitions from App.kt to DataModules.kt - Switch OfflineFirstRepository to pure Room reads (no background Firebase refresh) to prevent race conditions with pending deletes - Fix RoomRepository DI to use DelegatingLoginAndRegister instead of hardcoded OfflineLoginAndRegister (group mismatch bug) - Cache Firebase user group to avoid network calls on every operation - Add getByIds() to RecipeDao/IngredientDao, extract enrichMealsWithRecipes helper - Fix participant deletion passing wrong ID (uid vs participantRef) - Add Snackbar success message after creating/updating participants - Clean up schema versions to single v1, platform-specific DB paths - Rename iOS app from Futterbock_App to Futterbock --- .../schemas/data.local.AppDatabase/2.json | 507 ---------------- .../schemas/data.local.AppDatabase/3.json | 554 ------------------ .../kotlin/data/AppModePreferences.android.kt | 4 +- composeApp/src/commonMain/kotlin/App.kt | 16 +- .../src/commonMain/kotlin/data/AppMode.kt | 1 - .../kotlin/data/DelegatingRepository.kt | 2 +- .../kotlin/data/local/RoomRepository.kt | 83 +-- .../kotlin/data/local/dao/BaseDao.kt | 6 +- .../kotlin/data/local/dao/IngredientDao.kt | 3 + .../kotlin/data/local/dao/RecipeDao.kt | 3 + .../kotlin/data/sync/InitialSyncService.kt | 61 +- .../data/sync/OfflineFirstRepository.kt | 257 ++------ .../commonMain/kotlin/modules/DataModules.kt | 10 +- .../kotlin/modules/ServiceModules.kt | 2 - .../kotlin/services/SeedDataService.kt | 45 -- .../login/FirebaseLoginAndRegister.kt | 4 + .../new_participant/ActionsNewParticipant.kt | 1 + .../admin/new_participant/NewParicipant.kt | 16 + .../ViewModelNewParticipant.kt | 21 +- .../view/event/homescreen/DrawerContent.kt | 2 +- .../participants/HandleParticipantsActions.kt | 2 +- .../src/commonMain/kotlin/view/login/Login.kt | 2 +- .../view/navigation/RootNavController.kt | 14 +- .../kotlin/data/AppModePreferences.desktop.kt | 4 +- .../data/local/DatabaseBuilder.desktop.kt | 14 +- .../kotlin/data/AppModePreferences.ios.kt | 4 +- iosApp/Configuration/Config.xcconfig | 2 +- iosApp/iosApp.xcodeproj/project.pbxproj | 14 +- 28 files changed, 202 insertions(+), 1452 deletions(-) delete mode 100644 composeApp/schemas/data.local.AppDatabase/2.json delete mode 100644 composeApp/schemas/data.local.AppDatabase/3.json delete mode 100644 composeApp/src/commonMain/kotlin/services/SeedDataService.kt diff --git a/composeApp/schemas/data.local.AppDatabase/2.json b/composeApp/schemas/data.local.AppDatabase/2.json deleted file mode 100644 index a7b81916..00000000 --- a/composeApp/schemas/data.local.AppDatabase/2.json +++ /dev/null @@ -1,507 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "d06105c7c9b78412956b889939eb1efb", - "entities": [ - { - "tableName": "events", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` TEXT NOT NULL, `uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `eventType` TEXT NOT NULL, `kitchenSchedule` TEXT, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "group", - "columnName": "group", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "from", - "columnName": "from", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "to", - "columnName": "to", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "eventType", - "columnName": "eventType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kitchenSchedule", - "columnName": "kitchenSchedule", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "participants", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `allergies` TEXT NOT NULL, `intolerances` TEXT NOT NULL, `group` TEXT NOT NULL, `selectedGroup` TEXT NOT NULL, `birthdate` INTEGER, `eatingHabit` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "allergies", - "columnName": "allergies", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "intolerances", - "columnName": "intolerances", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "group", - "columnName": "group", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "selectedGroup", - "columnName": "selectedGroup", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "birthdate", - "columnName": "birthdate", - "affinity": "INTEGER" - }, - { - "fieldPath": "eatingHabit", - "columnName": "eatingHabit", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "firstName", - "columnName": "firstName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastName", - "columnName": "lastName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "recipes", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `cookingInstructions` TEXT NOT NULL, `notes` TEXT NOT NULL, `description` TEXT NOT NULL, `dietaryHabit` TEXT NOT NULL, `shoppingIngredients` TEXT NOT NULL, `materials` TEXT NOT NULL, `name` TEXT NOT NULL, `pageInCookbook` INTEGER NOT NULL, `price` TEXT NOT NULL, `season` TEXT NOT NULL, `foodIntolerances` TEXT NOT NULL, `source` TEXT NOT NULL, `time` TEXT NOT NULL, `skillLevel` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cookingInstructions", - "columnName": "cookingInstructions", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "notes", - "columnName": "notes", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "description", - "columnName": "description", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "dietaryHabit", - "columnName": "dietaryHabit", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shoppingIngredients", - "columnName": "shoppingIngredients", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "materials", - "columnName": "materials", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pageInCookbook", - "columnName": "pageInCookbook", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "price", - "columnName": "price", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "season", - "columnName": "season", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "foodIntolerances", - "columnName": "foodIntolerances", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "source", - "columnName": "source", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "skillLevel", - "columnName": "skillLevel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "ingredients", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `amountHaferl` REAL, `unitHaferl` TEXT, `amountTablespoon` REAL, `unitTablespoon` TEXT, `amountTeaspoon` REAL, `unitTeaspoon` TEXT, `amountWeight` REAL, `unitWeight` TEXT, `category` TEXT NOT NULL, `expirationDateInDays` INTEGER, `intolerances` TEXT NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "amountHaferl", - "columnName": "amountHaferl", - "affinity": "REAL" - }, - { - "fieldPath": "unitHaferl", - "columnName": "unitHaferl", - "affinity": "TEXT" - }, - { - "fieldPath": "amountTablespoon", - "columnName": "amountTablespoon", - "affinity": "REAL" - }, - { - "fieldPath": "unitTablespoon", - "columnName": "unitTablespoon", - "affinity": "TEXT" - }, - { - "fieldPath": "amountTeaspoon", - "columnName": "amountTeaspoon", - "affinity": "REAL" - }, - { - "fieldPath": "unitTeaspoon", - "columnName": "unitTeaspoon", - "affinity": "TEXT" - }, - { - "fieldPath": "amountWeight", - "columnName": "amountWeight", - "affinity": "REAL" - }, - { - "fieldPath": "unitWeight", - "columnName": "unitWeight", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "expirationDateInDays", - "columnName": "expirationDateInDays", - "affinity": "INTEGER" - }, - { - "fieldPath": "intolerances", - "columnName": "intolerances", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "meals", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `day` INTEGER NOT NULL, `mealType` TEXT NOT NULL, `recipeSelections` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "day", - "columnName": "day", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mealType", - "columnName": "mealType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "recipeSelections", - "columnName": "recipeSelections", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "eventId", - "columnName": "eventId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - }, - "foreignKeys": [ - { - "table": "events", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "eventId" - ], - "referencedColumns": [ - "uid" - ] - } - ] - }, - { - "tableName": "participant_times", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `participantRef` TEXT NOT NULL, `cookingGroup` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`participantRef`) REFERENCES `participants`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "from", - "columnName": "from", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "to", - "columnName": "to", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "participantRef", - "columnName": "participantRef", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cookingGroup", - "columnName": "cookingGroup", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "eventId", - "columnName": "eventId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - }, - "foreignKeys": [ - { - "table": "events", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "eventId" - ], - "referencedColumns": [ - "uid" - ] - }, - { - "table": "participants", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "participantRef" - ], - "referencedColumns": [ - "uid" - ] - } - ] - }, - { - "tableName": "materials", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `amount` INTEGER NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "source", - "columnName": "source", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "amount", - "columnName": "amount", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "multi_day_shopping_lists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `dailyLists` TEXT NOT NULL, PRIMARY KEY(`eventId`))", - "fields": [ - { - "fieldPath": "eventId", - "columnName": "eventId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "dailyLists", - "columnName": "dailyLists", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "eventId" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd06105c7c9b78412956b889939eb1efb')" - ] - } -} \ No newline at end of file diff --git a/composeApp/schemas/data.local.AppDatabase/3.json b/composeApp/schemas/data.local.AppDatabase/3.json deleted file mode 100644 index 03ad2090..00000000 --- a/composeApp/schemas/data.local.AppDatabase/3.json +++ /dev/null @@ -1,554 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 3, - "identityHash": "5ef09d435f0c553163086b30fa42993d", - "entities": [ - { - "tableName": "events", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` TEXT NOT NULL, `uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `eventType` TEXT NOT NULL, `kitchenSchedule` TEXT, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "group", - "columnName": "group", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "from", - "columnName": "from", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "to", - "columnName": "to", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "eventType", - "columnName": "eventType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kitchenSchedule", - "columnName": "kitchenSchedule", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "participants", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `allergies` TEXT NOT NULL, `intolerances` TEXT NOT NULL, `group` TEXT NOT NULL, `selectedGroup` TEXT NOT NULL, `birthdate` INTEGER, `eatingHabit` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "allergies", - "columnName": "allergies", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "intolerances", - "columnName": "intolerances", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "group", - "columnName": "group", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "selectedGroup", - "columnName": "selectedGroup", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "birthdate", - "columnName": "birthdate", - "affinity": "INTEGER" - }, - { - "fieldPath": "eatingHabit", - "columnName": "eatingHabit", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "firstName", - "columnName": "firstName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastName", - "columnName": "lastName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "recipes", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `cookingInstructions` TEXT NOT NULL, `notes` TEXT NOT NULL, `description` TEXT NOT NULL, `dietaryHabit` TEXT NOT NULL, `shoppingIngredients` TEXT NOT NULL, `materials` TEXT NOT NULL, `name` TEXT NOT NULL, `pageInCookbook` INTEGER NOT NULL, `price` TEXT NOT NULL, `season` TEXT NOT NULL, `foodIntolerances` TEXT NOT NULL, `source` TEXT NOT NULL, `time` TEXT NOT NULL, `skillLevel` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cookingInstructions", - "columnName": "cookingInstructions", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "notes", - "columnName": "notes", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "description", - "columnName": "description", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "dietaryHabit", - "columnName": "dietaryHabit", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shoppingIngredients", - "columnName": "shoppingIngredients", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "materials", - "columnName": "materials", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pageInCookbook", - "columnName": "pageInCookbook", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "price", - "columnName": "price", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "season", - "columnName": "season", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "foodIntolerances", - "columnName": "foodIntolerances", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "source", - "columnName": "source", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "skillLevel", - "columnName": "skillLevel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "ingredients", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `amountHaferl` REAL, `unitHaferl` TEXT, `amountTablespoon` REAL, `unitTablespoon` TEXT, `amountTeaspoon` REAL, `unitTeaspoon` TEXT, `amountWeight` REAL, `unitWeight` TEXT, `category` TEXT NOT NULL, `expirationDateInDays` INTEGER, `intolerances` TEXT NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "amountHaferl", - "columnName": "amountHaferl", - "affinity": "REAL" - }, - { - "fieldPath": "unitHaferl", - "columnName": "unitHaferl", - "affinity": "TEXT" - }, - { - "fieldPath": "amountTablespoon", - "columnName": "amountTablespoon", - "affinity": "REAL" - }, - { - "fieldPath": "unitTablespoon", - "columnName": "unitTablespoon", - "affinity": "TEXT" - }, - { - "fieldPath": "amountTeaspoon", - "columnName": "amountTeaspoon", - "affinity": "REAL" - }, - { - "fieldPath": "unitTeaspoon", - "columnName": "unitTeaspoon", - "affinity": "TEXT" - }, - { - "fieldPath": "amountWeight", - "columnName": "amountWeight", - "affinity": "REAL" - }, - { - "fieldPath": "unitWeight", - "columnName": "unitWeight", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "expirationDateInDays", - "columnName": "expirationDateInDays", - "affinity": "INTEGER" - }, - { - "fieldPath": "intolerances", - "columnName": "intolerances", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "meals", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `day` INTEGER NOT NULL, `mealType` TEXT NOT NULL, `recipeSelections` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "day", - "columnName": "day", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mealType", - "columnName": "mealType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "recipeSelections", - "columnName": "recipeSelections", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "eventId", - "columnName": "eventId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - }, - "foreignKeys": [ - { - "table": "events", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "eventId" - ], - "referencedColumns": [ - "uid" - ] - } - ] - }, - { - "tableName": "participant_times", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `from` INTEGER NOT NULL, `to` INTEGER NOT NULL, `participantRef` TEXT NOT NULL, `cookingGroup` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`eventId`) REFERENCES `events`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`participantRef`) REFERENCES `participants`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "from", - "columnName": "from", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "to", - "columnName": "to", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "participantRef", - "columnName": "participantRef", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cookingGroup", - "columnName": "cookingGroup", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "eventId", - "columnName": "eventId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - }, - "foreignKeys": [ - { - "table": "events", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "eventId" - ], - "referencedColumns": [ - "uid" - ] - }, - { - "table": "participants", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "participantRef" - ], - "referencedColumns": [ - "uid" - ] - } - ] - }, - { - "tableName": "materials", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `amount` INTEGER NOT NULL, PRIMARY KEY(`uid`))", - "fields": [ - { - "fieldPath": "uid", - "columnName": "uid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "source", - "columnName": "source", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "amount", - "columnName": "amount", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uid" - ] - } - }, - { - "tableName": "multi_day_shopping_lists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `dailyLists` TEXT NOT NULL, PRIMARY KEY(`eventId`))", - "fields": [ - { - "fieldPath": "eventId", - "columnName": "eventId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "dailyLists", - "columnName": "dailyLists", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "eventId" - ] - } - }, - { - "tableName": "pending_operations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `operationType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `parentId` TEXT, `payloadJson` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "operationType", - "columnName": "operationType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "entityId", - "columnName": "entityId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentId", - "columnName": "parentId", - "affinity": "TEXT" - }, - { - "fieldPath": "payloadJson", - "columnName": "payloadJson", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5ef09d435f0c553163086b30fa42993d')" - ] - } -} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/data/AppModePreferences.android.kt b/composeApp/src/androidMain/kotlin/data/AppModePreferences.android.kt index afedccd7..dc23106e 100644 --- a/composeApp/src/androidMain/kotlin/data/AppModePreferences.android.kt +++ b/composeApp/src/androidMain/kotlin/data/AppModePreferences.android.kt @@ -9,8 +9,8 @@ actual class AppModePreferences actual constructor() { } actual fun getAppMode(): AppMode { - val name = prefs.getString("app_mode", null) ?: return AppMode.ONLINE - return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.ONLINE } + val name = prefs.getString("app_mode", null) ?: return AppMode.OFFLINE_FIRST + return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.OFFLINE_FIRST } } actual fun setAppMode(mode: AppMode) { diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index eea0e56c..3d8aabc9 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -1,9 +1,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import data.AppMode import data.AppModeHolder -import data.AppModePreferences import data.sync.InitialSyncService import data.sync.SyncManager import modules.dataModules @@ -11,7 +9,6 @@ import modules.serviceModules import modules.viewModelModules import org.koin.compose.KoinApplication import org.koin.compose.koinInject -import org.koin.dsl.module import services.pdfService.PdfServiceImpl import view.navigation.RootNavController import services.pdfService.PdfServiceModule @@ -20,24 +17,15 @@ import view.theme.AppTheme @Composable fun App(pdfService: PdfServiceImpl) { - val prefs = remember { AppModePreferences() } - val appModeHolder = remember { AppModeHolder(prefs.getAppMode()) } - - val appModeModule = remember { - module { - single { appModeHolder } - single { prefs } - } - } - KoinApplication(application = { modules( - appModeModule, serviceModules, dataModules, viewModelModules + serviceModules, dataModules, viewModelModules ) }) { val pdfServiceModule: PdfServiceModule = koinInject() pdfServiceModule.setPdfService(pdfService) + val appModeHolder = koinInject() val syncManager = koinInject() val initialSyncService = koinInject() LaunchedEffect(Unit) { diff --git a/composeApp/src/commonMain/kotlin/data/AppMode.kt b/composeApp/src/commonMain/kotlin/data/AppMode.kt index 9b3bbcf3..2a4244d6 100644 --- a/composeApp/src/commonMain/kotlin/data/AppMode.kt +++ b/composeApp/src/commonMain/kotlin/data/AppMode.kt @@ -1,7 +1,6 @@ package data enum class AppMode { - ONLINE, OFFLINE_ONLY, OFFLINE_FIRST } diff --git a/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt b/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt index 8fa3d584..a11f44e8 100644 --- a/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt +++ b/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt @@ -17,7 +17,7 @@ class DelegatingRepository( private val active: EventRepository get() = when (appModeHolder.mode.value) { AppMode.OFFLINE_ONLY -> roomRepository - AppMode.ONLINE, AppMode.OFFLINE_FIRST -> offlineFirstRepository + AppMode.OFFLINE_FIRST -> offlineFirstRepository } override suspend fun deleteEvent(eventId: String) = active.deleteEvent(eventId) diff --git a/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt b/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt index bc63f69b..bdbac031 100644 --- a/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt +++ b/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import data.EventRepository import kotlin.time.Clock import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -138,25 +139,15 @@ class RoomRepository( override suspend fun deleteParticipant(participantId: String) { val userGroup = loginAndRegister.getCustomUserGroup() - val events = db.eventDao().getByGroup(userGroup) - var eventsList: List = emptyList() - events.collect { eventsList = it; return@collect } - - eventsList.forEach { event -> - val meals = db.mealDao().getByEventId(event.uid) - meals.forEach { meal -> - var mealUpdated = false - meal.recipeSelections.forEach { selection -> - if (selection.eaterIds.contains(participantId)) { - selection.eaterIds.remove(participantId) - mealUpdated = true - } - } - if (mealUpdated) { + val events = db.eventDao().getByGroup(userGroup).first() + + events.forEach { event -> + db.mealDao().getByEventId(event.uid) + .filter { meal -> meal.recipeSelections.any { it.eaterIds.contains(participantId) } } + .forEach { meal -> + meal.recipeSelections.forEach { it.eaterIds.remove(participantId) } db.mealDao().update(meal) } - } - db.participantTimeDao().deleteByEventAndParticipant(event.uid, participantId) } db.participantDao().deleteById(participantId) @@ -191,8 +182,7 @@ class RoomRepository( val ingredientRefs = recipe.shoppingIngredients.map { it.ingredientRef }.distinct() if (ingredientRefs.isNotEmpty()) { - val allIngredients = db.ingredientDao().getAll() - val ingredientMap = allIngredients.associateBy { it.uid } + val ingredientMap = db.ingredientDao().getByIds(ingredientRefs).associateBy { it.uid } recipe.shoppingIngredients.forEach { si -> si.ingredient = ingredientMap[si.ingredientRef] } @@ -221,45 +211,15 @@ class RoomRepository( // --- Meals --- - override suspend fun getAllMealsOfEvent(eventId: String): List { - val meals = db.mealDao().getByEventId(eventId) - - val allRecipeIds = meals.flatMap { it.recipeSelections.map { rs -> rs.recipeRef } }.distinct() - if (allRecipeIds.isNotEmpty()) { - val recipes = db.recipeDao().getAll() - val recipeMap = recipes.associateBy { it.uid } - - val allIngredients = db.ingredientDao().getAll() - val ingredientMap = allIngredients.associateBy { it.uid } - - meals.forEach { meal -> - meal.recipeSelections.forEach { selection -> - val recipe = recipeMap[selection.recipeRef] - if (recipe != null) { - recipe.shoppingIngredients.forEach { si -> - si.ingredient = ingredientMap[si.ingredientRef] - } - selection.recipe = recipe - } - } - } - } - - return meals - } - - override suspend fun getMealById(eventId: String, mealId: String): Meal { - val meal = db.mealDao().getById(eventId, mealId) - ?: throw NoSuchElementException("Meal $mealId not found in event $eventId") + private suspend fun enrichMealsWithRecipes(meals: List) { + val recipeIds = meals.flatMap { it.recipeSelections.map { rs -> rs.recipeRef } }.distinct() + if (recipeIds.isEmpty()) return - val recipeIds = meal.recipeSelections.map { it.recipeRef }.distinct() - if (recipeIds.isNotEmpty()) { - val recipes = db.recipeDao().getAll() - val recipeMap = recipes.associateBy { it.uid } - - val allIngredients = db.ingredientDao().getAll() - val ingredientMap = allIngredients.associateBy { it.uid } + val recipeMap = db.recipeDao().getByIds(recipeIds).associateBy { it.uid } + val ingredientIds = recipeMap.values.flatMap { it.shoppingIngredients.map { si -> si.ingredientRef } }.distinct() + val ingredientMap = if (ingredientIds.isNotEmpty()) db.ingredientDao().getByIds(ingredientIds).associateBy { it.uid } else emptyMap() + meals.forEach { meal -> meal.recipeSelections.forEach { selection -> val recipe = recipeMap[selection.recipeRef] if (recipe != null) { @@ -270,7 +230,18 @@ class RoomRepository( } } } + } + + override suspend fun getAllMealsOfEvent(eventId: String): List { + val meals = db.mealDao().getByEventId(eventId) + enrichMealsWithRecipes(meals) + return meals + } + override suspend fun getMealById(eventId: String, mealId: String): Meal { + val meal = db.mealDao().getById(eventId, mealId) + ?: throw NoSuchElementException("Meal $mealId not found in event $eventId") + enrichMealsWithRecipes(listOf(meal)) return meal } diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/BaseDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/BaseDao.kt index 99352ac8..3e7691f0 100644 --- a/composeApp/src/commonMain/kotlin/data/local/dao/BaseDao.kt +++ b/composeApp/src/commonMain/kotlin/data/local/dao/BaseDao.kt @@ -1,16 +1,14 @@ package data.local.dao import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Update import androidx.room.Upsert interface BaseDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Upsert suspend fun insert(entity: T) - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Upsert suspend fun insertAll(entities: List) @Update diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/IngredientDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/IngredientDao.kt index 0029f76b..b28c7547 100644 --- a/composeApp/src/commonMain/kotlin/data/local/dao/IngredientDao.kt +++ b/composeApp/src/commonMain/kotlin/data/local/dao/IngredientDao.kt @@ -11,4 +11,7 @@ interface IngredientDao : BaseDao { @Query("SELECT * FROM ingredients WHERE uid = :uid") suspend fun getById(uid: String): Ingredient? + + @Query("SELECT * FROM ingredients WHERE uid IN (:ids)") + suspend fun getByIds(ids: List): List } diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt index 9493a49c..aa84498f 100644 --- a/composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt +++ b/composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt @@ -15,6 +15,9 @@ interface RecipeDao : BaseDao { @Query("SELECT * FROM recipes WHERE uid = :uid") suspend fun getById(uid: String): Recipe? + @Query("SELECT * FROM recipes WHERE uid IN (:ids)") + suspend fun getByIds(ids: List): List + @Query("DELETE FROM recipes WHERE uid = :uid") suspend fun deleteById(uid: String) } diff --git a/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt b/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt index d887f703..50c47c1a 100644 --- a/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt +++ b/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt @@ -1,6 +1,7 @@ package data.sync import co.touchlab.kermit.Logger +import data.AppModePreferences import data.FireBaseRepository import data.local.AppDatabase import kotlinx.coroutines.flow.first @@ -9,32 +10,53 @@ import services.login.FirebaseLoginAndRegister class InitialSyncService( private val db: AppDatabase, private val firebaseRepository: FireBaseRepository, - private val loginAndRegister: FirebaseLoginAndRegister + private val loginAndRegister: FirebaseLoginAndRegister, + private val networkMonitor: NetworkMonitor, + private val prefs: AppModePreferences ) { - suspend fun syncAllUserData() { - if (!loginAndRegister.isAuthenticated()) return + suspend fun syncBaseData(): Boolean { + if (!networkMonitor.isOnline.value) return false + if (prefs.isSeedDataDownloaded()) return true - try { - val group = loginAndRegister.getCustomUserGroup() - Logger.i("InitialSync: Starting full sync for group $group") + return try { + Logger.i("InitialSync: Syncing base data (recipes, ingredients, materials)") val recipes = firebaseRepository.getAllRecipes() recipes .filter { it.uid.isNotBlank() } .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } - .forEach { db.recipeDao().insert(it) } + db.recipeDao().insertAll(recipes.filter { it.uid.isNotBlank() }) Logger.i("InitialSync: Synced ${recipes.size} recipes") val ingredients = firebaseRepository.getAllIngredients() - ingredients.filter { it.uid.isNotBlank() }.forEach { db.ingredientDao().insert(it) } + db.ingredientDao().insertAll(ingredients.filter { it.uid.isNotBlank() }) Logger.i("InitialSync: Synced ${ingredients.size} ingredients") val materials = firebaseRepository.getAllMaterials() - materials.filter { it.uid.isNotBlank() }.forEach { db.materialDao().insert(it) } + db.materialDao().insertAll(materials.filter { it.uid.isNotBlank() }) Logger.i("InitialSync: Synced ${materials.size} materials") + prefs.setSeedDataDownloaded(true) + Logger.i("InitialSync: Base data sync complete") + true + } catch (e: Exception) { + Logger.e("InitialSync: Base data sync failed: ${e.message}", e) + false + } + } + + suspend fun syncAllUserData() { + if (!networkMonitor.isOnline.value) return + if (!loginAndRegister.isAuthenticated()) return + + try { + val group = loginAndRegister.getCustomUserGroup() + Logger.i("InitialSync: Starting full sync for group $group") + + syncBaseData() + val events = firebaseRepository.getEventList(group).first() - events.forEach { db.eventDao().insert(it) } + db.eventDao().insertAll(events) Logger.i("InitialSync: Synced ${events.size} events") events.forEach { event -> @@ -42,17 +64,16 @@ class InitialSyncService( val participantTimes = firebaseRepository.getParticipantsOfEvent(event.uid, true) participantTimes.forEach { pt -> pt.eventId = event.uid - db.participantTimeDao().insert(pt) - if (pt.participant != null) { - db.participantDao().insert(pt.participant!!) - } + } + db.participantTimeDao().insertAll(participantTimes) + val participants = participantTimes.mapNotNull { it.participant } + if (participants.isNotEmpty()) { + db.participantDao().insertAll(participants) } val meals = firebaseRepository.getAllMealsOfEvent(event.uid) - meals.forEach { meal -> - meal.eventId = event.uid - db.mealDao().insert(meal) - } + meals.forEach { it.eventId = event.uid } + db.mealDao().insertAll(meals) val shoppingList = firebaseRepository.getMultiDayShoppingList(event.uid) if (shoppingList != null) { @@ -60,7 +81,9 @@ class InitialSyncService( } val eventMaterials = firebaseRepository.getMaterialListOfEvent(event.uid) - eventMaterials.forEach { db.materialDao().insert(it) } + if (eventMaterials.isNotEmpty()) { + db.materialDao().insertAll(eventMaterials) + } } catch (e: Exception) { Logger.e("InitialSync: Error syncing event ${event.uid}: ${e.message}") } diff --git a/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt b/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt index 6e08dfbb..cb35e641 100644 --- a/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt +++ b/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt @@ -7,7 +7,6 @@ import data.local.AppDatabase import data.local.RoomRepository import kotlin.time.Clock import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.onEach import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.serialization.encodeToString @@ -66,16 +65,8 @@ class OfflineFirstRepository( } } - override suspend fun getEventById(eventId: String): Event? { - if (isOnline) { - try { - val event = firebaseRepository.getEventById(eventId) - if (event != null) roomRepository.saveExistingEvent(event) - return event - } catch (_: Exception) {} - } - return roomRepository.getEventById(eventId) - } + override suspend fun getEventById(eventId: String): Event? = + roomRepository.getEventById(eventId) override suspend fun createNewEvent(): Event { val event = roomRepository.createNewEvent() @@ -92,56 +83,19 @@ class OfflineFirstRepository( } } - override suspend fun getEventList(group: String): Flow> { - if (isOnline) { - try { - return firebaseRepository.getEventList(group).onEach { events -> - events.forEach { event -> - db.eventDao().insert(event) - } - } - } catch (_: Exception) {} - } - return roomRepository.getEventList(group) - } + override suspend fun getEventList(group: String): Flow> = + roomRepository.getEventList(group) // --- Participants --- - override suspend fun getNumberOfParticipants(eventId: String): Int { - if (isOnline) { - try { return firebaseRepository.getNumberOfParticipants(eventId) } - catch (_: Exception) {} - } - return roomRepository.getNumberOfParticipants(eventId) - } + override suspend fun getNumberOfParticipants(eventId: String): Int = + roomRepository.getNumberOfParticipants(eventId) - override suspend fun getParticipantsOfEvent(eventId: String, withParticipant: Boolean): List { - if (isOnline) { - try { - val participantTimes = firebaseRepository.getParticipantsOfEvent(eventId, withParticipant) - participantTimes.forEach { pt -> - pt.eventId = eventId - db.participantTimeDao().insert(pt) - if (pt.participant != null) { - db.participantDao().insert(pt.participant!!) - } - } - return participantTimes - } catch (_: Exception) {} - } - return roomRepository.getParticipantsOfEvent(eventId, withParticipant) - } + override suspend fun getParticipantsOfEvent(eventId: String, withParticipant: Boolean): List = + roomRepository.getParticipantsOfEvent(eventId, withParticipant) - override suspend fun getAllParticipantsOfStamm(): Flow> { - if (isOnline) { - try { - return firebaseRepository.getAllParticipantsOfStamm().onEach { participants -> - participants.forEach { db.participantDao().insert(it) } - } - } catch (_: Exception) {} - } - return roomRepository.getAllParticipantsOfStamm() - } + override suspend fun getAllParticipantsOfStamm(): Flow> = + roomRepository.getAllParticipantsOfStamm() override suspend fun deleteParticipantOfEvent(eventId: String, participantId: String) { roomRepository.deleteParticipantOfEvent(eventId, participantId) @@ -180,24 +134,11 @@ class OfflineFirstRepository( } } - override suspend fun getParticipantById(participantId: String): Participant? { - if (isOnline) { - try { - val participant = firebaseRepository.getParticipantById(participantId) - if (participant != null) db.participantDao().insert(participant) - return participant - } catch (_: Exception) {} - } - return roomRepository.getParticipantById(participantId) - } + override suspend fun getParticipantById(participantId: String): Participant? = + roomRepository.getParticipantById(participantId) - override suspend fun findParticipantByName(firstName: String, lastName: String): Participant? { - if (isOnline) { - try { return firebaseRepository.findParticipantByName(firstName, lastName) } - catch (_: Exception) {} - } - return roomRepository.findParticipantByName(firstName, lastName) - } + override suspend fun findParticipantByName(firstName: String, lastName: String): Participant? = + roomRepository.findParticipantByName(firstName, lastName) override suspend fun updateParticipantTime(eventId: String, participant: ParticipantTime) { roomRepository.updateParticipantTime(eventId, participant) @@ -208,63 +149,14 @@ class OfflineFirstRepository( // --- Recipes --- - override suspend fun getAllRecipes(): List { - if (isOnline) { - try { - val recipes = firebaseRepository.getAllRecipes() - recipes - .filter { it.uid.isNotBlank() } - .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } - .forEach { db.recipeDao().insert(it) } - return recipes - } catch (_: Exception) {} - } - return roomRepository.getAllRecipes() - } + override suspend fun getAllRecipes(): List = + roomRepository.getAllRecipes() - override suspend fun getUserCreatedRecipes(): List { - if (isOnline) { - try { - val recipes = firebaseRepository.getUserCreatedRecipes() - recipes - .filter { it.uid.isNotBlank() } - .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } - .forEach { db.recipeDao().insert(it) } - return recipes - } catch (_: Exception) {} - } - return roomRepository.getUserCreatedRecipes() - } + override suspend fun getUserCreatedRecipes(): List = + roomRepository.getUserCreatedRecipes() - override suspend fun getRecipeById(recipeId: String): Recipe? { - if (isOnline) { - try { - val recipe = firebaseRepository.getRecipeById(recipeId) - if (recipe != null && recipe.uid.isNotBlank()) { - val toCache = Recipe().apply { - uid = recipe.uid; name = recipe.name; description = recipe.description - cookingInstructions = recipe.cookingInstructions; notes = recipe.notes - dietaryHabit = recipe.dietaryHabit; materials = recipe.materials - pageInCookbook = recipe.pageInCookbook; price = recipe.price - season = recipe.season; foodIntolerances = recipe.foodIntolerances - source = recipe.source; time = recipe.time; skillLevel = recipe.skillLevel - type = recipe.type - shoppingIngredients = recipe.shoppingIngredients.map { si -> - ShoppingIngredient().apply { - uid = si.uid; ingredientRef = si.ingredientRef - nameEnteredByUser = si.nameEnteredByUser; amount = si.amount - unit = si.unit; title = si.title; shoppingDone = si.shoppingDone - note = si.note; source = si.source - } - } - } - db.recipeDao().insert(toCache) - } - return recipe - } catch (_: Exception) {} - } - return roomRepository.getRecipeById(recipeId) - } + override suspend fun getRecipeById(recipeId: String): Recipe? = + roomRepository.getRecipeById(recipeId) override suspend fun createRecipe(recipe: Recipe) { roomRepository.createRecipe(recipe) @@ -289,31 +181,11 @@ class OfflineFirstRepository( // --- Meals --- - override suspend fun getAllMealsOfEvent(eventId: String): List { - if (isOnline) { - try { - val meals = firebaseRepository.getAllMealsOfEvent(eventId) - meals.forEach { meal -> - meal.eventId = eventId - db.mealDao().insert(meal) - } - return meals - } catch (_: Exception) {} - } - return roomRepository.getAllMealsOfEvent(eventId) - } + override suspend fun getAllMealsOfEvent(eventId: String): List = + roomRepository.getAllMealsOfEvent(eventId) - override suspend fun getMealById(eventId: String, mealId: String): Meal { - if (isOnline) { - try { - val meal = firebaseRepository.getMealById(eventId, mealId) - meal.eventId = eventId - db.mealDao().insert(meal) - return meal - } catch (_: Exception) {} - } - return roomRepository.getMealById(eventId, mealId) - } + override suspend fun getMealById(eventId: String, mealId: String): Meal = + roomRepository.getMealById(eventId, mealId) override suspend fun createNewMeal(eventId: String, day: Instant): Meal { val meal = roomRepository.createNewMeal(eventId, day) @@ -339,54 +211,19 @@ class OfflineFirstRepository( // --- Ingredients --- - override suspend fun getIngredientById(ingredientId: String): Ingredient { - if (isOnline) { - try { - val ingredient = firebaseRepository.getIngredientById(ingredientId) - db.ingredientDao().insert(ingredient) - return ingredient - } catch (_: Exception) {} - } - return roomRepository.getIngredientById(ingredientId) - } + override suspend fun getIngredientById(ingredientId: String): Ingredient = + roomRepository.getIngredientById(ingredientId) - override suspend fun getMealsWithRecipeAndIngredients(eventId: String): List { - if (isOnline) { - try { - val meals = firebaseRepository.getMealsWithRecipeAndIngredients(eventId) - meals.forEach { meal -> - meal.eventId = eventId - db.mealDao().insert(meal) - } - return meals - } catch (_: Exception) {} - } - return roomRepository.getMealsWithRecipeAndIngredients(eventId) - } + override suspend fun getMealsWithRecipeAndIngredients(eventId: String): List = + roomRepository.getMealsWithRecipeAndIngredients(eventId) - override suspend fun getAllIngredients(): List { - if (isOnline) { - try { - val ingredients = firebaseRepository.getAllIngredients() - ingredients.filter { it.uid.isNotBlank() }.forEach { db.ingredientDao().insert(it) } - return ingredients - } catch (_: Exception) {} - } - return roomRepository.getAllIngredients() - } + override suspend fun getAllIngredients(): List = + roomRepository.getAllIngredients() // --- Shopping Lists --- - override suspend fun getMultiDayShoppingList(eventId: String): MultiDayShoppingList? { - if (isOnline) { - try { - val list = firebaseRepository.getMultiDayShoppingList(eventId) - if (list != null) db.multiDayShoppingListDao().insert(list) - return list - } catch (_: Exception) {} - } - return roomRepository.getMultiDayShoppingList(eventId) - } + override suspend fun getMultiDayShoppingList(eventId: String): MultiDayShoppingList? = + roomRepository.getMultiDayShoppingList(eventId) override suspend fun saveMultiDayShoppingList(eventId: String, multiDayShoppingList: MultiDayShoppingList) { roomRepository.saveMultiDayShoppingList(eventId, multiDayShoppingList) @@ -395,10 +232,8 @@ class OfflineFirstRepository( } } - override suspend fun getDailyShoppingList(eventId: String, date: LocalDate): DailyShoppingList? { - val multiDayList = getMultiDayShoppingList(eventId) - return multiDayList?.dailyLists?.get(date) - } + override suspend fun getDailyShoppingList(eventId: String, date: LocalDate): DailyShoppingList? = + roomRepository.getDailyShoppingList(eventId, date) override suspend fun saveDailyShoppingList(eventId: String, date: LocalDate, dailyShoppingList: DailyShoppingList) { val multiDayList = getMultiDayShoppingList(eventId) @@ -433,16 +268,8 @@ class OfflineFirstRepository( } } - override suspend fun getMaterialListOfEvent(eventId: String): List { - if (isOnline) { - try { - val materials = firebaseRepository.getMaterialListOfEvent(eventId) - materials.forEach { db.materialDao().insert(it) } - return materials - } catch (_: Exception) {} - } - return roomRepository.getMaterialListOfEvent(eventId) - } + override suspend fun getMaterialListOfEvent(eventId: String): List = + roomRepository.getMaterialListOfEvent(eventId) override suspend fun deleteMaterialById(eventId: String, materialId: String) { roomRepository.deleteMaterialById(eventId, materialId) @@ -451,14 +278,6 @@ class OfflineFirstRepository( } } - override suspend fun getAllMaterials(): List { - if (isOnline) { - try { - val materials = firebaseRepository.getAllMaterials() - materials.filter { it.uid.isNotBlank() }.forEach { db.materialDao().insert(it) } - return materials - } catch (_: Exception) {} - } - return roomRepository.getAllMaterials() - } + override suspend fun getAllMaterials(): List = + roomRepository.getAllMaterials() } diff --git a/composeApp/src/commonMain/kotlin/modules/DataModules.kt b/composeApp/src/commonMain/kotlin/modules/DataModules.kt index a52a1766..163dd8e7 100644 --- a/composeApp/src/commonMain/kotlin/modules/DataModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/DataModules.kt @@ -1,5 +1,7 @@ package modules +import data.AppModeHolder +import data.AppModePreferences import data.DelegatingRepository import data.EventRepository import data.FireBaseRepository @@ -13,9 +15,13 @@ import data.sync.OfflineFirstRepository import data.sync.SyncManager import org.koin.dsl.module import services.login.FirebaseLoginAndRegister +import services.login.LoginAndRegister import services.login.OfflineLoginAndRegister val dataModules = module { + single { AppModePreferences() } + single { AppModeHolder(get().getAppMode()) } + single { getDatabaseBuilder().build() } @@ -23,10 +29,10 @@ val dataModules = module { single { FirebaseLoginAndRegister() } single { OfflineLoginAndRegister() } single { FireBaseRepository(get()) } - single { RoomRepository(get(), get()) } + single { RoomRepository(get(), get()) } single { NetworkMonitorImpl() } single { SyncManager(get(), get(), get()) } - single { InitialSyncService(get(), get(), get()) } + single { InitialSyncService(get(), get(), get(), get(), get()) } single { OfflineFirstRepository(get(), get(), get(), get()) } single { diff --git a/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt b/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt index 94742165..d4a1660f 100644 --- a/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt @@ -1,7 +1,6 @@ package modules import org.koin.dsl.module -import services.SeedDataService import services.shoppingList.CalculateShoppingList import services.ChangeDateOfEvent import services.login.LoginAndRegister @@ -13,7 +12,6 @@ import services.update.UpdateChecker val serviceModules = module { single { DelegatingLoginAndRegister(get(), get(), get()) } - single { SeedDataService(get(), get(), get()) } single { CalculateShoppingList(get()) } single { CalculateMaterialList(get()) } single { PdfServiceModule(get(), get()) } diff --git a/composeApp/src/commonMain/kotlin/services/SeedDataService.kt b/composeApp/src/commonMain/kotlin/services/SeedDataService.kt deleted file mode 100644 index 1afd6b57..00000000 --- a/composeApp/src/commonMain/kotlin/services/SeedDataService.kt +++ /dev/null @@ -1,45 +0,0 @@ -package services - -import co.touchlab.kermit.Logger -import data.AppModePreferences -import data.FireBaseRepository -import data.local.AppDatabase - -class SeedDataService( - private val db: AppDatabase, - private val prefs: AppModePreferences, - private val firebaseRepo: FireBaseRepository -) { - suspend fun downloadSeedDataIfNeeded(): Boolean { - if (prefs.isSeedDataDownloaded()) return true - - return try { - - val recipes = firebaseRepo.getAllRecipes() - Logger.i("SeedData: Downloaded ${recipes.size} recipes") - recipes - .filter { it.uid.isNotBlank() } - .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } - .forEach { db.recipeDao().insert(it) } - - val ingredients = firebaseRepo.getAllIngredients() - Logger.i("SeedData: Downloaded ${ingredients.size} ingredients") - ingredients - .filter { it.uid.isNotBlank() } - .forEach { db.ingredientDao().insert(it) } - - val materials = firebaseRepo.getAllMaterials() - Logger.i("SeedData: Downloaded ${materials.size} materials") - materials - .filter { it.uid.isNotBlank() } - .forEach { db.materialDao().insert(it) } - - prefs.setSeedDataDownloaded(true) - Logger.i("SeedData: Download complete") - true - } catch (e: Exception) { - Logger.e("SeedData: Download failed: ${e.message}", e) - false - } - } -} diff --git a/composeApp/src/commonMain/kotlin/services/login/FirebaseLoginAndRegister.kt b/composeApp/src/commonMain/kotlin/services/login/FirebaseLoginAndRegister.kt index 09eb1095..8e491c7a 100644 --- a/composeApp/src/commonMain/kotlin/services/login/FirebaseLoginAndRegister.kt +++ b/composeApp/src/commonMain/kotlin/services/login/FirebaseLoginAndRegister.kt @@ -11,14 +11,17 @@ private const val STAMM = "group" class FirebaseLoginAndRegister : LoginAndRegister { + private var cachedGroup: String? = null override suspend fun getCustomUserGroup(): String { + cachedGroup?.let { return it } if (!isAuthenticated()) { return "" } val userId = Firebase.auth.currentUser!!.uid val userDocRef = Firebase.firestore.collection(USER_COLLECTION).document(userId) val stamm = userDocRef.get().get(STAMM) + cachedGroup = stamm return stamm } @@ -50,6 +53,7 @@ class FirebaseLoginAndRegister : LoginAndRegister { } override suspend fun logout() { + cachedGroup = null Firebase.auth.signOut() } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/view/admin/new_participant/ActionsNewParticipant.kt b/composeApp/src/commonMain/kotlin/view/admin/new_participant/ActionsNewParticipant.kt index 6fd76a29..c9bdd31d 100644 --- a/composeApp/src/commonMain/kotlin/view/admin/new_participant/ActionsNewParticipant.kt +++ b/composeApp/src/commonMain/kotlin/view/admin/new_participant/ActionsNewParticipant.kt @@ -27,4 +27,5 @@ interface ActionsNewParticipant : BaseAction { } data object Save : ActionsNewParticipant + data object DismissSuccess : ActionsNewParticipant } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/view/admin/new_participant/NewParicipant.kt b/composeApp/src/commonMain/kotlin/view/admin/new_participant/NewParicipant.kt index e7068b82..a54460bb 100644 --- a/composeApp/src/commonMain/kotlin/view/admin/new_participant/NewParicipant.kt +++ b/composeApp/src/commonMain/kotlin/view/admin/new_participant/NewParicipant.kt @@ -32,6 +32,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar @@ -115,6 +118,18 @@ fun NewParicipant( ) { val focusManager = LocalFocusManager.current + val snackbarHostState = remember { SnackbarHostState() } + val successMessage = (state as? ResultState.Success)?.data?.successMessage + + LaunchedEffect(successMessage) { + if (successMessage != null) { + snackbarHostState.showSnackbar( + message = successMessage, + duration = SnackbarDuration.Short + ) + onAction(ActionsNewParticipant.DismissSuccess) + } + } AppTheme { Scaffold( @@ -123,6 +138,7 @@ fun NewParicipant( .pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { TopAppBar(title = { Text(text = "Teilnehmende hinzufügen") diff --git a/composeApp/src/commonMain/kotlin/view/admin/new_participant/ViewModelNewParticipant.kt b/composeApp/src/commonMain/kotlin/view/admin/new_participant/ViewModelNewParticipant.kt index af69c1d4..d1aa6fc9 100644 --- a/composeApp/src/commonMain/kotlin/view/admin/new_participant/ViewModelNewParticipant.kt +++ b/composeApp/src/commonMain/kotlin/view/admin/new_participant/ViewModelNewParticipant.kt @@ -25,6 +25,7 @@ data class NewParticipantState( val isNewParticipant: Boolean = true, val allergies: List = emptyList(), val selectedGroup: String = "", + val successMessage: String? = null, ) class ViewModelNewParticipant( @@ -57,6 +58,7 @@ class ViewModelNewParticipant( ) is ActionsNewParticipant.DeleteParticipant -> deleteParticipant(actionsNewParticipant.participantId) + is ActionsNewParticipant.DismissSuccess -> dismissSuccess() } } @@ -112,10 +114,12 @@ class ViewModelNewParticipant( allergies = data.allergies selectedGroup = data.selectedGroup } + val name = "${data.firstName} ${data.lastName}" + val isNew = data.isNewParticipant viewModelScope.launch { try { - if (data.isNewParticipant) { - Logger.i("Create new Participant with name ${participant.firstName} ${participant.lastName}") + if (isNew) { + Logger.i("Create new Participant with name $name") val success = eventRepository.createNewParticipant(participant) if (success == null) { _state.value = @@ -123,17 +127,26 @@ class ViewModelNewParticipant( return@launch } } else { - Logger.i("Update Participant with name ${participant.firstName} ${participant.lastName}") + Logger.i("Update Participant with name $name") eventRepository.updateParticipant(participant) } } catch (e: Exception) { Logger.e("" + e.message) - ResultState.Error("Fehler beim anlegen des Teilnehmenden") + _state.value = ResultState.Error("Fehler beim Anlegen des Teilnehmenden") + return@launch } initializeScreenWithNewParticipant() + val currentData = state.value.getSuccessData() ?: return@launch + val message = if (isNew) "$name wurde erstellt" else "$name wurde aktualisiert" + _state.value = ResultState.Success(currentData.copy(successMessage = message)) } } + private fun dismissSuccess() { + val data = state.value.getSuccessData() ?: return + _state.value = ResultState.Success(data.copy(successMessage = null)) + } + private fun initializeWithParticipant(participant: Participant) { _state.value = ResultState.Success( NewParticipantState( diff --git a/composeApp/src/commonMain/kotlin/view/event/homescreen/DrawerContent.kt b/composeApp/src/commonMain/kotlin/view/event/homescreen/DrawerContent.kt index 2021b0cf..62b7fcf5 100644 --- a/composeApp/src/commonMain/kotlin/view/event/homescreen/DrawerContent.kt +++ b/composeApp/src/commonMain/kotlin/view/event/homescreen/DrawerContent.kt @@ -74,7 +74,7 @@ fun DrawerContent( selected = false, onClick = { scope.launch { - appModeHolder.switchMode(AppMode.ONLINE) + appModeHolder.switchMode(AppMode.OFFLINE_FIRST) login.logout() onLogoutNavigation() } diff --git a/composeApp/src/commonMain/kotlin/view/event/participants/HandleParticipantsActions.kt b/composeApp/src/commonMain/kotlin/view/event/participants/HandleParticipantsActions.kt index 047928b3..69eaa4cc 100644 --- a/composeApp/src/commonMain/kotlin/view/event/participants/HandleParticipantsActions.kt +++ b/composeApp/src/commonMain/kotlin/view/event/participants/HandleParticipantsActions.kt @@ -105,7 +105,7 @@ class HandleParticipantsActions( } eventRepository.deleteParticipantOfEvent( currentState.event.uid, - participantId = participantToDelete.uid + participantId = participantToDelete.participantRef ) return ResultState.Success( currentState.copy( diff --git a/composeApp/src/commonMain/kotlin/view/login/Login.kt b/composeApp/src/commonMain/kotlin/view/login/Login.kt index 7ea47d8a..b286a0e2 100644 --- a/composeApp/src/commonMain/kotlin/view/login/Login.kt +++ b/composeApp/src/commonMain/kotlin/view/login/Login.kt @@ -100,7 +100,7 @@ fun LoginScreen( try { onOfflineSelected() } catch (e: Exception) { - loginError = "Fehler beim Laden der Daten. Bitte überprüfe deine Internetverbindung." + loginError = "Fehler beim Laden der Rezepte. Bitte überprüfe deine Internetverbindung." } finally { loading = false } diff --git a/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt b/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt index 6c8938c9..7183b19e 100644 --- a/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt +++ b/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt @@ -10,8 +10,8 @@ import androidx.navigation.toRoute import data.AppMode import data.AppModeHolder import data.EventRepository +import data.sync.InitialSyncService import org.koin.compose.koinInject -import services.SeedDataService import services.login.LoginAndRegister import view.admin.csv_import.CsvImportScreen import view.admin.new_participant.NewParticipantScreen @@ -41,7 +41,7 @@ fun RootNavController( val loginService = koinInject() val eventRepository = koinInject() val appModeHolder = koinInject() - val seedDataService = koinInject() + val initialSyncService = koinInject() NavHost( navController = navController, @@ -55,7 +55,7 @@ fun RootNavController( navigateToRegister = { navController.navigate(Routes.Register) }, navigateToHome = { navController.navigate(Routes.Home) }, onOfflineSelected = { - seedDataService.downloadSeedDataIfNeeded() + initialSyncService.syncBaseData() appModeHolder.switchMode(AppMode.OFFLINE_ONLY) navController.navigate(Routes.Home) { popUpTo(Routes.Login) { inclusive = true } @@ -139,7 +139,9 @@ fun RootNavController( } fun getStartDestination(loginService: LoginAndRegister, appMode: AppMode): Any { - if (appMode == AppMode.OFFLINE_ONLY) return Routes.Home - if (loginService.isAuthenticated()) return Routes.Home - return Routes.Login + return when { + appMode == AppMode.OFFLINE_ONLY -> Routes.Home + loginService.isAuthenticated() -> Routes.Home + else -> Routes.Login + } } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/data/AppModePreferences.desktop.kt b/composeApp/src/desktopMain/kotlin/data/AppModePreferences.desktop.kt index d0a9220a..b0addc0b 100644 --- a/composeApp/src/desktopMain/kotlin/data/AppModePreferences.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/data/AppModePreferences.desktop.kt @@ -6,8 +6,8 @@ actual class AppModePreferences actual constructor() { private val prefs = Preferences.userNodeForPackage(AppModePreferences::class.java) actual fun getAppMode(): AppMode { - val name = prefs.get("app_mode", null) ?: return AppMode.ONLINE - return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.ONLINE } + val name = prefs.get("app_mode", null) ?: return AppMode.OFFLINE_FIRST + return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.OFFLINE_FIRST } } actual fun setAppMode(mode: AppMode) { diff --git a/composeApp/src/desktopMain/kotlin/data/local/DatabaseBuilder.desktop.kt b/composeApp/src/desktopMain/kotlin/data/local/DatabaseBuilder.desktop.kt index 44592bd1..99d8c4eb 100644 --- a/composeApp/src/desktopMain/kotlin/data/local/DatabaseBuilder.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/data/local/DatabaseBuilder.desktop.kt @@ -6,7 +6,19 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import java.io.File actual fun getDatabaseBuilder(): RoomDatabase.Builder { - val dbDir = File(System.getProperty("user.home"), ".futterbock") + val osName = System.getProperty("os.name").lowercase() + val dbDir = when { + osName.contains("mac") -> + File(System.getProperty("user.home"), "Library/Application Support/Futterbock") + osName.contains("win") -> + File(System.getenv("APPDATA") ?: System.getProperty("user.home"), "Futterbock") + else -> + File( + System.getenv("XDG_DATA_HOME") + ?: "${System.getProperty("user.home")}/.local/share", + "futterbock" + ) + } dbDir.mkdirs() val dbFile = File(dbDir, "futterbock.db") return Room.databaseBuilder(name = dbFile.absolutePath) diff --git a/composeApp/src/iosMain/kotlin/data/AppModePreferences.ios.kt b/composeApp/src/iosMain/kotlin/data/AppModePreferences.ios.kt index 51993918..ccead691 100644 --- a/composeApp/src/iosMain/kotlin/data/AppModePreferences.ios.kt +++ b/composeApp/src/iosMain/kotlin/data/AppModePreferences.ios.kt @@ -6,8 +6,8 @@ actual class AppModePreferences actual constructor() { private val defaults = NSUserDefaults.standardUserDefaults actual fun getAppMode(): AppMode { - val name = defaults.stringForKey("app_mode") ?: return AppMode.ONLINE - return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.ONLINE } + val name = defaults.stringForKey("app_mode") ?: return AppMode.OFFLINE_FIRST + return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.OFFLINE_FIRST } } actual fun setAppMode(mode: AppMode) { diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index df4dfeb3..79a3ce72 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,3 +1,3 @@ TEAM_ID= BUNDLE_ID=org.futterbock.app.FutterbockApp -APP_NAME=Futterbock_App \ No newline at end of file +APP_NAME=Futterbock \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c256998d..3016963f 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -21,7 +21,7 @@ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; - 7555FF7B242A565900829871 /* Futterbock_App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Futterbock_App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7555FF7B242A565900829871 /* Futterbock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Futterbock.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A2B2A19F2CB27F14005F4BA8 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -85,7 +85,7 @@ 7555FF7C242A565900829871 /* Products */ = { isa = PBXGroup; children = ( - 7555FF7B242A565900829871 /* Futterbock_App.app */, + 7555FF7B242A565900829871 /* Futterbock.app */, ); name = Products; sourceTree = ""; @@ -134,7 +134,7 @@ A2D424BC2CEA1D250051BAF0 /* FirebaseFirestore */, ); productName = iosApp; - productReference = 7555FF7B242A565900829871 /* Futterbock_App.app */; + productReference = 7555FF7B242A565900829871 /* Futterbock.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -353,7 +353,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = 4U5J277GB9; + DEVELOPMENT_TEAM = BP92C82JT8; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -365,7 +365,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.futterbock.app; + PRODUCT_BUNDLE_IDENTIFIER = com.futterbock.app2; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; @@ -380,7 +380,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = 4U5J277GB9; + DEVELOPMENT_TEAM = BP92C82JT8; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -392,7 +392,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.futterbock.app; + PRODUCT_BUNDLE_IDENTIFIER = com.futterbock.app2; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; From b7e993b07220d6343e7a0b517a1e9b5f5952c0c4 Mon Sep 17 00:00:00 2001 From: Frederik Kischewski Date: Thu, 7 May 2026 21:13:24 +0200 Subject: [PATCH 7/9] fix: make participant search rows clickable across full width --- .../event/participants/ParticipantSearchBar.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/view/event/participants/ParticipantSearchBar.kt b/composeApp/src/commonMain/kotlin/view/event/participants/ParticipantSearchBar.kt index ca3e743a..24da292c 100644 --- a/composeApp/src/commonMain/kotlin/view/event/participants/ParticipantSearchBar.kt +++ b/composeApp/src/commonMain/kotlin/view/event/participants/ParticipantSearchBar.kt @@ -226,12 +226,15 @@ fun ParticipantSearchBar( ) }.sortedBy { it.firstName }.forEach { Row( - modifier = Modifier.padding(16.dp).clickable { - searchText = "" - participantsAddedInThisStep = - participantsAddedInThisStep + it - onAction(EditParticipantActions.AddParticipant(it)) - } + modifier = Modifier.fillMaxWidth() + .clickable { + searchText = "" + participantsAddedInThisStep = + participantsAddedInThisStep + it + onAction(EditParticipantActions.AddParticipant(it)) + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { Text(text = it.firstName.trim() + " " + it.lastName.trim()) } From 5416f446c09eb9345d93361b42873a3b4ffe165d Mon Sep 17 00:00:00 2001 From: Frederik Kischewski Date: Thu, 7 May 2026 21:20:29 +0200 Subject: [PATCH 8/9] fix: address code review findings - SyncManager: skip failed pending ops instead of blocking entire queue - SyncManager: remove leaked CoroutineScope, use suspend startObserving() bound to LaunchedEffect lifecycle - Material: add eventId field, filter getMaterialListOfEvent by eventId instead of returning all materials - Move LoginAndRegister binding from ServiceModules to DataModules to eliminate fragile cross-module dependency - Fix syncBaseData() lazy evaluation bug where ingredient null-clearing was never executed (discarded filter+onEach chain) --- .../kotlin/data/local/RoomRepository.kt | 5 ++-- .../kotlin/data/local/dao/MaterialDao.kt | 3 +++ .../kotlin/data/sync/InitialSyncService.kt | 4 +-- .../kotlin/data/sync/SyncManager.kt | 26 +++++++------------ .../src/commonMain/kotlin/model/Material.kt | 3 +++ .../commonMain/kotlin/modules/DataModules.kt | 2 ++ .../kotlin/modules/ServiceModules.kt | 3 --- 7 files changed, 22 insertions(+), 24 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt b/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt index bdbac031..80b6e1f2 100644 --- a/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt +++ b/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt @@ -342,14 +342,13 @@ class RoomRepository( override suspend fun saveMaterialList(eventId: String, materialList: List) { materialList.forEach { material -> + material.eventId = eventId db.materialDao().insert(material) } } override suspend fun getMaterialListOfEvent(eventId: String): List { - // Materials are stored globally; event-scoped material list uses the same table - // For now, return all materials. Event-specific filtering can be added later. - return db.materialDao().getAll() + return db.materialDao().getByEventId(eventId) } override suspend fun deleteMaterialById(eventId: String, materialId: String) { diff --git a/composeApp/src/commonMain/kotlin/data/local/dao/MaterialDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/MaterialDao.kt index 2852c45f..d51861c6 100644 --- a/composeApp/src/commonMain/kotlin/data/local/dao/MaterialDao.kt +++ b/composeApp/src/commonMain/kotlin/data/local/dao/MaterialDao.kt @@ -9,6 +9,9 @@ interface MaterialDao : BaseDao { @Query("SELECT * FROM materials") suspend fun getAll(): List + @Query("SELECT * FROM materials WHERE eventId = :eventId") + suspend fun getByEventId(eventId: String): List + @Query("DELETE FROM materials WHERE uid = :uid") suspend fun deleteById(uid: String) } diff --git a/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt b/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt index 50c47c1a..f52ff522 100644 --- a/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt +++ b/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt @@ -22,10 +22,9 @@ class InitialSyncService( Logger.i("InitialSync: Syncing base data (recipes, ingredients, materials)") val recipes = firebaseRepository.getAllRecipes() - recipes .filter { it.uid.isNotBlank() } .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } - db.recipeDao().insertAll(recipes.filter { it.uid.isNotBlank() }) + db.recipeDao().insertAll(recipes) Logger.i("InitialSync: Synced ${recipes.size} recipes") val ingredients = firebaseRepository.getAllIngredients() @@ -81,6 +80,7 @@ class InitialSyncService( } val eventMaterials = firebaseRepository.getMaterialListOfEvent(event.uid) + eventMaterials.forEach { it.eventId = event.uid } if (eventMaterials.isNotEmpty()) { db.materialDao().insertAll(eventMaterials) } diff --git a/composeApp/src/commonMain/kotlin/data/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/data/sync/SyncManager.kt index dc84016d..3be9cfd8 100644 --- a/composeApp/src/commonMain/kotlin/data/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/data/sync/SyncManager.kt @@ -3,9 +3,9 @@ package data.sync import co.touchlab.kermit.Logger import data.FireBaseRepository import data.local.AppDatabase -import kotlinx.coroutines.* -import kotlinx.datetime.Instant -import kotlinx.datetime.LocalDate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import model.* @@ -15,17 +15,14 @@ class SyncManager( private val networkMonitor: NetworkMonitor ) { private val json = Json { ignoreUnknownKeys = true } - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - fun startObserving() { - scope.launch { - networkMonitor.isOnline - .collect { online -> - if (online) { - processPendingOperations() - } + suspend fun startObserving() { + networkMonitor.isOnline + .collect { online -> + if (online) { + processPendingOperations() } - } + } } suspend fun processPendingOperations() { @@ -41,7 +38,7 @@ class SyncManager( Logger.d("SyncManager: Completed operation ${op.operationType} for ${op.entityId}") } catch (e: Exception) { Logger.e("SyncManager: Failed operation ${op.operationType} for ${op.entityId}: ${e.message}") - break + db.pendingOperationDao().deleteById(op.id) } } } @@ -110,7 +107,4 @@ class SyncManager( } } - fun stop() { - scope.cancel() - } } diff --git a/composeApp/src/commonMain/kotlin/model/Material.kt b/composeApp/src/commonMain/kotlin/model/Material.kt index 13e3e8b4..b3745488 100644 --- a/composeApp/src/commonMain/kotlin/model/Material.kt +++ b/composeApp/src/commonMain/kotlin/model/Material.kt @@ -14,6 +14,9 @@ class Material : ListItem { var source: Source = Source.ENTERED_BY_USER var amount: Int = 0 + @kotlinx.serialization.Transient + var eventId: String? = null + override fun getListItemTitle(): String { return name } diff --git a/composeApp/src/commonMain/kotlin/modules/DataModules.kt b/composeApp/src/commonMain/kotlin/modules/DataModules.kt index 163dd8e7..47b4c6a7 100644 --- a/composeApp/src/commonMain/kotlin/modules/DataModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/DataModules.kt @@ -14,6 +14,7 @@ import data.sync.InitialSyncService import data.sync.OfflineFirstRepository import data.sync.SyncManager import org.koin.dsl.module +import services.login.DelegatingLoginAndRegister import services.login.FirebaseLoginAndRegister import services.login.LoginAndRegister import services.login.OfflineLoginAndRegister @@ -28,6 +29,7 @@ val dataModules = module { single { FirebaseLoginAndRegister() } single { OfflineLoginAndRegister() } + single { DelegatingLoginAndRegister(get(), get(), get()) } single { FireBaseRepository(get()) } single { RoomRepository(get(), get()) } single { NetworkMonitorImpl() } diff --git a/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt b/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt index d4a1660f..fa1a1c2e 100644 --- a/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/ServiceModules.kt @@ -3,15 +3,12 @@ package modules import org.koin.dsl.module import services.shoppingList.CalculateShoppingList import services.ChangeDateOfEvent -import services.login.LoginAndRegister -import services.login.DelegatingLoginAndRegister import services.materiallist.CalculateMaterialList import services.pdfService.PdfServiceModule import services.event.ParticipantCanEatRecipe import services.update.UpdateChecker val serviceModules = module { - single { DelegatingLoginAndRegister(get(), get(), get()) } single { CalculateShoppingList(get()) } single { CalculateMaterialList(get()) } single { PdfServiceModule(get(), get()) } From 1a45bf782cf286c720637f395575b2bb6fbbfdec Mon Sep 17 00:00:00 2001 From: Frederik Kischewski Date: Thu, 7 May 2026 21:32:43 +0200 Subject: [PATCH 9/9] fix: update tests for ParticipantTime constructor change Set participant field via .also {} instead of constructor parameter since it is @Ignore in Room. --- composeApp/src/commonTest/kotlin/data/FakeEventRepository.kt | 5 +++-- .../cookingGroups/CookingGroupIngredientServiceTest.kt | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonTest/kotlin/data/FakeEventRepository.kt b/composeApp/src/commonTest/kotlin/data/FakeEventRepository.kt index 35901478..be398256 100644 --- a/composeApp/src/commonTest/kotlin/data/FakeEventRepository.kt +++ b/composeApp/src/commonTest/kotlin/data/FakeEventRepository.kt @@ -62,9 +62,10 @@ class FakeEventRepository : EventRepository { uid = id, from = Clock.System.now(), to = Clock.System.now(), - participant = if (withParticipant) participant else null, participantRef = participant.uid - ) + ).also { + if (withParticipant) it.participant = participant + } } } } diff --git a/composeApp/src/commonTest/kotlin/services/cookingGroups/CookingGroupIngredientServiceTest.kt b/composeApp/src/commonTest/kotlin/services/cookingGroups/CookingGroupIngredientServiceTest.kt index de1db192..0d59165d 100644 --- a/composeApp/src/commonTest/kotlin/services/cookingGroups/CookingGroupIngredientServiceTest.kt +++ b/composeApp/src/commonTest/kotlin/services/cookingGroups/CookingGroupIngredientServiceTest.kt @@ -634,12 +634,11 @@ class CookingGroupIngredientServiceTest : KoinTest { } return ParticipantTime( - participant = participant, from = Clock.System.now(), to = Clock.System.now(), uid = "pt_$participantId", participantRef = participantId, cookingGroup = cookingGroup - ) + ).also { it.participant = participant } } } \ No newline at end of file