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..5a342d29 --- /dev/null +++ b/composeApp/schemas/data.local.AppDatabase/1.json @@ -0,0 +1,585 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "a349f4d52240b32de9df9cc39e775a74", + "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" + ] + }, + "indices": [ + { + "name": "index_meals_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meals_eventId` ON `${TABLE_NAME}` (`eventId`)" + } + ], + "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" + ] + }, + "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", + "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, 'a349f4d52240b32de9df9cc39e775a74')" + ] + } +} \ 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..dc23106e --- /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.OFFLINE_FIRST + return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.OFFLINE_FIRST } + } + + 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/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 947bebe4..3d8aabc9 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -1,5 +1,9 @@ -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import data.AppMode +import data.AppModeHolder +import data.sync.InitialSyncService +import data.sync.SyncManager import modules.dataModules import modules.serviceModules import modules.viewModelModules @@ -15,22 +19,24 @@ import view.theme.AppTheme fun App(pdfService: PdfServiceImpl) { KoinApplication(application = { modules( - dataModules, viewModelModules, serviceModules + serviceModules, dataModules, viewModelModules ) }) { val pdfServiceModule: PdfServiceModule = koinInject() - pdfServiceModule.setPdfService(pdfService) + val appModeHolder = koinInject() + val syncManager = koinInject() + val initialSyncService = koinInject() + LaunchedEffect(Unit) { + if (appModeHolder.mode.value != AppMode.OFFLINE_ONLY) { + syncManager.startObserving() + initialSyncService.syncAllUserData() + } + } + 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..2a4244d6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/AppMode.kt @@ -0,0 +1,6 @@ +package data + +enum class AppMode { + 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..a11f44e8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/DelegatingRepository.kt @@ -0,0 +1,63 @@ +package data + +import data.local.RoomRepository +import data.sync.OfflineFirstRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import model.* + +class DelegatingRepository( + private val appModeHolder: AppModeHolder, + private val firebaseRepository: FireBaseRepository, + private val roomRepository: RoomRepository, + private val offlineFirstRepository: OfflineFirstRepository +) : EventRepository { + + private val active: EventRepository + get() = when (appModeHolder.mode.value) { + AppMode.OFFLINE_ONLY -> roomRepository + AppMode.OFFLINE_FIRST -> offlineFirstRepository + } + + 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..6e53df3b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/AppDatabase.kt @@ -0,0 +1,41 @@ +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, + Participant::class, + Recipe::class, + Ingredient::class, + Meal::class, + ParticipantTime::class, + Material::class, + MultiDayShoppingList::class, + PendingOperation::class, + ], + version = 1, + exportSchema = true +) +@TypeConverters(Converters::class) +@ConstructedBy(AppDatabaseConstructor::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 + abstract fun pendingOperationDao(): PendingOperationDao +} 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..80b6e1f2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/RoomRepository.kt @@ -0,0 +1,361 @@ +package data.local + +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 +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().update(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().update(participant) + } + + override suspend fun deleteParticipant(participantId: String) { + val userGroup = loginAndRegister.getCustomUserGroup() + 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.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().update(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 ingredientMap = db.ingredientDao().getByIds(ingredientRefs).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().update(recipe) + } + + override suspend fun deleteRecipe(recipeId: String) { + db.recipeDao().deleteById(recipeId) + } + + // --- Meals --- + + private suspend fun enrichMealsWithRecipes(meals: List) { + val recipeIds = meals.flatMap { it.recipeSelections.map { rs -> rs.recipeRef } }.distinct() + if (recipeIds.isEmpty()) return + + 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) { + recipe.shoppingIngredients.forEach { si -> + si.ingredient = ingredientMap[si.ingredientRef] + } + selection.recipe = recipe + } + } + } + } + + 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 + } + + 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().update(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 -> + material.eventId = eventId + db.materialDao().insert(material) + } + } + + override suspend fun getMaterialListOfEvent(eventId: String): List { + return db.materialDao().getByEventId(eventId) + } + + 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..3e7691f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/BaseDao.kt @@ -0,0 +1,19 @@ +package data.local.dao + +import androidx.room.Delete +import androidx.room.Update +import androidx.room.Upsert + +interface BaseDao { + @Upsert + suspend fun insert(entity: T) + + @Upsert + 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..b28c7547 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/IngredientDao.kt @@ -0,0 +1,17 @@ +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? + + @Query("SELECT * FROM ingredients WHERE uid IN (:ids)") + suspend fun getByIds(ids: List): List +} 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..d51861c6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/MaterialDao.kt @@ -0,0 +1,17 @@ +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("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/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/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/local/dao/RecipeDao.kt b/composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt new file mode 100644 index 00000000..aa84498f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/local/dao/RecipeDao.kt @@ -0,0 +1,23 @@ +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("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 new file mode 100644 index 00000000..f52ff522 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/sync/InitialSyncService.kt @@ -0,0 +1,97 @@ +package data.sync + +import co.touchlab.kermit.Logger +import data.AppModePreferences +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, + private val networkMonitor: NetworkMonitor, + private val prefs: AppModePreferences +) { + suspend fun syncBaseData(): Boolean { + if (!networkMonitor.isOnline.value) return false + if (prefs.isSeedDataDownloaded()) return true + + return try { + Logger.i("InitialSync: Syncing base data (recipes, ingredients, materials)") + + val recipes = firebaseRepository.getAllRecipes() + .filter { it.uid.isNotBlank() } + .onEach { it.shoppingIngredients.forEach { si -> si.ingredient = null } } + db.recipeDao().insertAll(recipes) + Logger.i("InitialSync: Synced ${recipes.size} recipes") + + val ingredients = firebaseRepository.getAllIngredients() + db.ingredientDao().insertAll(ingredients.filter { it.uid.isNotBlank() }) + Logger.i("InitialSync: Synced ${ingredients.size} ingredients") + + val materials = firebaseRepository.getAllMaterials() + 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() + db.eventDao().insertAll(events) + 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().insertAll(participantTimes) + val participants = participantTimes.mapNotNull { it.participant } + if (participants.isNotEmpty()) { + db.participantDao().insertAll(participants) + } + + val meals = firebaseRepository.getAllMealsOfEvent(event.uid) + meals.forEach { it.eventId = event.uid } + db.mealDao().insertAll(meals) + + val shoppingList = firebaseRepository.getMultiDayShoppingList(event.uid) + if (shoppingList != null) { + db.multiDayShoppingListDao().insert(shoppingList) + } + + val eventMaterials = firebaseRepository.getMaterialListOfEvent(event.uid) + eventMaterials.forEach { it.eventId = event.uid } + if (eventMaterials.isNotEmpty()) { + db.materialDao().insertAll(eventMaterials) + } + } 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/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..cb35e641 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/sync/OfflineFirstRepository.kt @@ -0,0 +1,283 @@ +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() + ) + ) + } + + 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) + writeToFirebaseOrQueue("DELETE_EVENT", eventId) { + firebaseRepository.deleteEvent(eventId) + } + } + + override suspend fun getEventById(eventId: String): Event? = + roomRepository.getEventById(eventId) + + override suspend fun createNewEvent(): Event { + val event = roomRepository.createNewEvent() + writeToFirebaseOrQueue("CREATE_EVENT", event.uid, payload = json.encodeToString(event)) { + firebaseRepository.saveExistingEvent(event) + } + return event + } + + override suspend fun saveExistingEvent(event: Event) { + roomRepository.saveExistingEvent(event) + writeToFirebaseOrQueue("UPDATE_EVENT", event.uid, payload = json.encodeToString(event)) { + firebaseRepository.saveExistingEvent(event) + } + } + + override suspend fun getEventList(group: String): Flow> = + roomRepository.getEventList(group) + + // --- Participants --- + + override suspend fun getNumberOfParticipants(eventId: String): Int = + roomRepository.getNumberOfParticipants(eventId) + + override suspend fun getParticipantsOfEvent(eventId: String, withParticipant: Boolean): List = + roomRepository.getParticipantsOfEvent(eventId, withParticipant) + + override suspend fun getAllParticipantsOfStamm(): Flow> = + roomRepository.getAllParticipantsOfStamm() + + override suspend fun deleteParticipantOfEvent(eventId: String, participantId: String) { + roomRepository.deleteParticipantOfEvent(eventId, participantId) + 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) + 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 + writeToFirebaseOrQueue("CREATE_PARTICIPANT", result.uid, payload = json.encodeToString(result)) { + firebaseRepository.createNewParticipant(result) + } + return result + } + + override suspend fun updateParticipant(participant: Participant) { + roomRepository.updateParticipant(participant) + writeToFirebaseOrQueue("UPDATE_PARTICIPANT", participant.uid, payload = json.encodeToString(participant)) { + firebaseRepository.updateParticipant(participant) + } + } + + override suspend fun deleteParticipant(participantId: String) { + roomRepository.deleteParticipant(participantId) + writeToFirebaseOrQueue("DELETE_PARTICIPANT", participantId) { + firebaseRepository.deleteParticipant(participantId) + } + } + + override suspend fun getParticipantById(participantId: String): Participant? = + roomRepository.getParticipantById(participantId) + + 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) + writeToFirebaseOrQueue("UPDATE_PARTICIPANT_TIME", participant.uid, parentId = eventId, payload = json.encodeToString(participant)) { + firebaseRepository.updateParticipantTime(eventId, participant) + } + } + + // --- Recipes --- + + override suspend fun getAllRecipes(): List = + roomRepository.getAllRecipes() + + override suspend fun getUserCreatedRecipes(): List = + roomRepository.getUserCreatedRecipes() + + override suspend fun getRecipeById(recipeId: String): Recipe? = + roomRepository.getRecipeById(recipeId) + + override suspend fun createRecipe(recipe: Recipe) { + roomRepository.createRecipe(recipe) + writeToFirebaseOrQueue("CREATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) { + firebaseRepository.createRecipe(recipe) + } + } + + override suspend fun updateRecipe(recipe: Recipe) { + roomRepository.updateRecipe(recipe) + writeToFirebaseOrQueue("UPDATE_RECIPE", recipe.uid, payload = json.encodeToString(recipe)) { + firebaseRepository.updateRecipe(recipe) + } + } + + override suspend fun deleteRecipe(recipeId: String) { + roomRepository.deleteRecipe(recipeId) + writeToFirebaseOrQueue("DELETE_RECIPE", recipeId) { + firebaseRepository.deleteRecipe(recipeId) + } + } + + // --- Meals --- + + override suspend fun getAllMealsOfEvent(eventId: String): List = + roomRepository.getAllMealsOfEvent(eventId) + + 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) + 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) + writeToFirebaseOrQueue("DELETE_MEAL", mealId, parentId = eventId) { + firebaseRepository.deleteMeal(eventId, mealId) + } + } + + override suspend fun updateMeal(eventId: String, meal: Meal) { + roomRepository.updateMeal(eventId, meal) + writeToFirebaseOrQueue("UPDATE_MEAL", meal.uid, parentId = eventId, payload = json.encodeToString(meal)) { + firebaseRepository.updateMeal(eventId, meal) + } + } + + // --- Ingredients --- + + override suspend fun getIngredientById(ingredientId: String): Ingredient = + roomRepository.getIngredientById(ingredientId) + + override suspend fun getMealsWithRecipeAndIngredients(eventId: String): List = + roomRepository.getMealsWithRecipeAndIngredients(eventId) + + override suspend fun getAllIngredients(): List = + roomRepository.getAllIngredients() + + // --- Shopping Lists --- + + override suspend fun getMultiDayShoppingList(eventId: String): MultiDayShoppingList? = + roomRepository.getMultiDayShoppingList(eventId) + + override suspend fun saveMultiDayShoppingList(eventId: String, multiDayShoppingList: MultiDayShoppingList) { + roomRepository.saveMultiDayShoppingList(eventId, multiDayShoppingList) + writeToFirebaseOrQueue("SAVE_MULTI_DAY_SHOPPING_LIST", eventId, parentId = eventId, payload = json.encodeToString(multiDayShoppingList)) { + firebaseRepository.saveMultiDayShoppingList(eventId, multiDayShoppingList) + } + } + + 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) + 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) + writeToFirebaseOrQueue("SAVE_MATERIAL_LIST", eventId, parentId = eventId, payload = json.encodeToString(materialList)) { + firebaseRepository.saveMaterialList(eventId, materialList) + } + } + + override suspend fun getMaterialListOfEvent(eventId: String): List = + roomRepository.getMaterialListOfEvent(eventId) + + override suspend fun deleteMaterialById(eventId: String, materialId: String) { + roomRepository.deleteMaterialById(eventId, materialId) + writeToFirebaseOrQueue("DELETE_MATERIAL", materialId, parentId = eventId) { + firebaseRepository.deleteMaterialById(eventId, materialId) + } + } + + override suspend fun getAllMaterials(): List = + 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..3be9cfd8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/data/sync/SyncManager.kt @@ -0,0 +1,110 @@ +package data.sync + +import co.touchlab.kermit.Logger +import data.FireBaseRepository +import data.local.AppDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +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 } + + suspend fun startObserving() { + 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}") + db.pendingOperationDao().deleteById(op.id) + } + } + } + + 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}") + } + } + +} 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..b3745488 100644 --- a/composeApp/src/commonMain/kotlin/model/Material.kt +++ b/composeApp/src/commonMain/kotlin/model/Material.kt @@ -1,16 +1,22 @@ 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 + @kotlinx.serialization.Transient + var eventId: String? = null + override fun getListItemTitle(): String { return name } diff --git a/composeApp/src/commonMain/kotlin/model/Meal.kt b/composeApp/src/commonMain/kotlin/model/Meal.kt index a3c23815..c6e6c024 100644 --- a/composeApp/src/commonMain/kotlin/model/Meal.kt +++ b/composeApp/src/commonMain/kotlin/model/Meal.kt @@ -1,17 +1,34 @@ 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 import view.shared.list.ListItem @Serializable +@Entity( + tableName = "meals", + foreignKeys = [ForeignKey( + entity = Event::class, + parentColumns = ["uid"], + childColumns = ["eventId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index("eventId")] +) 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..fbd4fe6e 100644 --- a/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt +++ b/composeApp/src/commonMain/kotlin/model/ParticipantTime.kt @@ -1,5 +1,10 @@ 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 import kotlinx.serialization.Transient @@ -7,17 +12,40 @@ import view.shared.HelperFunctions import view.shared.list.ListItem @Serializable +@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 + ) + ], + indices = [Index("eventId"), Index("participantRef")] +) 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/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/modules/DataModules.kt b/composeApp/src/commonMain/kotlin/modules/DataModules.kt index d677948d..47b4c6a7 100644 --- a/composeApp/src/commonMain/kotlin/modules/DataModules.kt +++ b/composeApp/src/commonMain/kotlin/modules/DataModules.kt @@ -1,9 +1,43 @@ package modules +import data.AppModeHolder +import data.AppModePreferences +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.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 val dataModules = module { - single { FireBaseRepository(get()) } + single { AppModePreferences() } + single { AppModeHolder(get().getAppMode()) } + + single { + getDatabaseBuilder().build() + } + + single { FirebaseLoginAndRegister() } + single { OfflineLoginAndRegister() } + single { DelegatingLoginAndRegister(get(), get(), get()) } + single { FireBaseRepository(get()) } + single { RoomRepository(get(), get()) } + single { NetworkMonitorImpl() } + single { SyncManager(get(), get(), get()) } + single { InitialSyncService(get(), get(), get(), get(), get()) } + single { OfflineFirstRepository(get(), get(), get(), get()) } + + single { + 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 c38fdc22..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.FirebaseLoginAndRegister import services.materiallist.CalculateMaterialList import services.pdfService.PdfServiceModule import services.event.ParticipantCanEatRecipe import services.update.UpdateChecker val serviceModules = module { - single { FirebaseLoginAndRegister() } single { CalculateShoppingList(get()) } single { CalculateMaterialList(get()) } single { PdfServiceModule(get(), get()) } 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..50db84da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/services/login/DelegatingLoginAndRegister.kt @@ -0,0 +1,24 @@ +package services.login + +import data.AppMode +import data.AppModeHolder + +class DelegatingLoginAndRegister( + private val appModeHolder: AppModeHolder, + private val firebase: FirebaseLoginAndRegister, + private val offline: OfflineLoginAndRegister +) : LoginAndRegister { + + 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/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/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/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 c672cf72..62b7fcf5 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.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/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()) } diff --git a/composeApp/src/commonMain/kotlin/view/login/Login.kt b/composeApp/src/commonMain/kotlin/view/login/Login.kt index 52bbf04d..b286a0e2 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 Rezepte. 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 (Internetverbindung erforderlich).", + 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..7183b19e 100644 --- a/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt +++ b/composeApp/src/commonMain/kotlin/view/navigation/RootNavController.kt @@ -7,7 +7,10 @@ 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 data.sync.InitialSyncService import org.koin.compose.koinInject import services.login.LoginAndRegister import view.admin.csv_import.CsvImportScreen @@ -37,10 +40,12 @@ fun RootNavController( val navController = rememberNavController() val loginService = koinInject() val eventRepository = koinInject() + val appModeHolder = koinInject() + val initialSyncService = 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 = { + initialSyncService.syncBaseData() + appModeHolder.switchMode(AppMode.OFFLINE_ONLY) + navController.navigate(Routes.Home) { + popUpTo(Routes.Login) { inclusive = true } + } + } ) } composable { @@ -126,9 +138,10 @@ fun RootNavController( } } -fun getStartDestination(loginService: LoginAndRegister): Any { - if (loginService.isAuthenticated()) { - return Routes.Home +fun getStartDestination(loginService: LoginAndRegister, appMode: AppMode): Any { + return when { + appMode == AppMode.OFFLINE_ONLY -> Routes.Home + loginService.isAuthenticated() -> Routes.Home + else -> Routes.Login } - return Routes.Login } \ No newline at end of file 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 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..b0addc0b --- /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.OFFLINE_FIRST + return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.OFFLINE_FIRST } + } + + 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..99d8c4eb --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/data/local/DatabaseBuilder.desktop.kt @@ -0,0 +1,26 @@ +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 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) + .setDriver(BundledSQLiteDriver()) +} 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/AppModePreferences.ios.kt b/composeApp/src/iosMain/kotlin/data/AppModePreferences.ios.kt new file mode 100644 index 00000000..ccead691 --- /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.OFFLINE_FIRST + return try { AppMode.valueOf(name) } catch (_: Exception) { AppMode.OFFLINE_FIRST } + } + + 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/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) + } +} 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" } 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;