diff --git a/core/common/src/commonMain/kotlin/io/github/droidkaigi/confsched/compose/ComposeEffectErrorHandler.kt b/core/common/src/commonMain/kotlin/io/github/droidkaigi/confsched/compose/ComposeEffectErrorHandler.kt index fe91110d5..c1b5a0a29 100644 --- a/core/common/src/commonMain/kotlin/io/github/droidkaigi/confsched/compose/ComposeEffectErrorHandler.kt +++ b/core/common/src/commonMain/kotlin/io/github/droidkaigi/confsched/compose/ComposeEffectErrorHandler.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidedValue import androidx.compose.runtime.State import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.produceState import androidx.compose.runtime.staticCompositionLocalOf import io.github.takahirom.rin.produceRetainedState import kotlinx.coroutines.CancellationException @@ -57,6 +58,28 @@ fun SafeLaunchedEffect(key: Any?, block: suspend CoroutineScope.() -> Unit) { } } +@Composable +fun StateFlow.safeCollectAsState( + context: CoroutineContext = EmptyCoroutineContext, +): State { + val composeEffectErrorHandler = LocalComposeEffectErrorHandler.current + return produceState(value, this, context) { + try { + if (context == EmptyCoroutineContext) { + collect { value = it } + } else { + withContext(context) { + collect { value = it } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + composeEffectErrorHandler.emit(e) + } + } +} + @Composable fun Flow.safeCollectAsRetainedState( initial: R, diff --git a/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched/data/user/UserDataStoreModule.kt b/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched/data/user/UserDataStoreModule.kt index 60a6d7cf5..3e493a62f 100644 --- a/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched/data/user/UserDataStoreModule.kt +++ b/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched/data/user/UserDataStoreModule.kt @@ -6,6 +6,8 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import javax.inject.Singleton @InstallIn(SingletonComponent::class) @@ -18,6 +20,9 @@ public class UserDataStoreModule { @UserDataStoreQualifier dataStore: DataStore, ): UserDataStore { - return UserDataStore(dataStore) + return UserDataStore( + dataStore = dataStore, + coroutineScope = CoroutineScope(Job()), + ) } } diff --git a/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/sessions/DefaultSessionsRepository.kt b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/sessions/DefaultSessionsRepository.kt index 1e5ba29e4..cf4236e48 100644 --- a/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/sessions/DefaultSessionsRepository.kt +++ b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/sessions/DefaultSessionsRepository.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue import co.touchlab.kermit.Logger import io.github.droidkaigi.confsched.compose.SafeLaunchedEffect import io.github.droidkaigi.confsched.compose.safeCollectAsRetainedState +import io.github.droidkaigi.confsched.compose.safeCollectAsState import io.github.droidkaigi.confsched.data.sessions.response.SessionsAllResponse import io.github.droidkaigi.confsched.data.user.UserDataStore import io.github.droidkaigi.confsched.model.DroidKaigi2024Day @@ -17,7 +18,6 @@ import io.github.droidkaigi.confsched.model.SessionsRepository import io.github.droidkaigi.confsched.model.Timetable import io.github.droidkaigi.confsched.model.TimetableItem import io.github.droidkaigi.confsched.model.TimetableItemId -import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch @@ -44,7 +44,7 @@ public class DefaultSessionsRepository( refreshSessionData() emitAll(sessionCacheDataStore.getTimetableStream()) }, - userDataStore.getFavoriteSessionStream(), + userDataStore.getFavoriteSessionStream, ) { timetable, favorites -> timetable.copy(bookmarks = favorites) } @@ -110,8 +110,8 @@ public class DefaultSessionsRepository( } }.safeCollectAsRetainedState(Timetable()) val favoriteSessions by remember { - userDataStore.getFavoriteSessionStream() - }.safeCollectAsRetainedState(persistentSetOf()) + userDataStore.getFavoriteSessionStream + }.safeCollectAsState() Logger.d { "DefaultSessionsRepository timetable() count=${timetable.timetableItems.size}" } return timetable.copy(bookmarks = favoriteSessions) diff --git a/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/user/UserDataStore.kt b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/user/UserDataStore.kt index c2d5c84ef..36085452a 100644 --- a/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/user/UserDataStore.kt +++ b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/user/UserDataStore.kt @@ -8,21 +8,28 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import io.github.droidkaigi.confsched.model.TimetableItemId import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn -public class UserDataStore(private val dataStore: DataStore) { +public class UserDataStore( + private val dataStore: DataStore, + coroutineScope: CoroutineScope, +) { private val mutableIdToken = MutableStateFlow(null) public val idToken: StateFlow = mutableIdToken - public fun getFavoriteSessionStream(): Flow> { - return dataStore.data + public val getFavoriteSessionStream: StateFlow> = + dataStore.data .catch { exception -> if (exception is IOException) { emit(emptyPreferences()) @@ -34,11 +41,14 @@ public class UserDataStore(private val dataStore: DataStore) { (preferences[KEY_FAVORITE_SESSION_IDS]?.split(",") ?: listOf()) .map { TimetableItemId(it) } .toPersistentSet() - } - } + }.stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = persistentSetOf(), + ) public suspend fun toggleFavorite(id: TimetableItemId) { - val updatedFavorites = getFavoriteSessionStream().first().toMutableSet() + val updatedFavorites = getFavoriteSessionStream.first().toMutableSet() if (updatedFavorites.contains(id)) { updatedFavorites.remove(id) diff --git a/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt b/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt index eb86ed48b..f891beee5 100644 --- a/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt +++ b/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt @@ -46,6 +46,7 @@ import io.ktor.client.plugins.logging.Logging import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf @@ -109,7 +110,10 @@ public val dataModule: Module = module { requireNotNull(documentDirectory).path + "/confsched2024.preferences_pb" }, ) - UserDataStore(dataStore) + UserDataStore( + dataStore = dataStore, + coroutineScope = CoroutineScope(Job()), + ) } single { val dataStore = createDataStore( diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/data/TestUserDataStoreModule.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/data/TestUserDataStoreModule.kt new file mode 100644 index 000000000..8e32d6bbe --- /dev/null +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/data/TestUserDataStoreModule.kt @@ -0,0 +1,33 @@ +package io.github.droidkaigi.confsched.testing.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.github.droidkaigi.confsched.data.user.UserDataStore +import io.github.droidkaigi.confsched.data.user.UserDataStoreModule +import io.github.droidkaigi.confsched.data.user.UserDataStoreQualifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.TestDispatcher +import javax.inject.Singleton + +@Module +@TestInstallIn(components = [SingletonComponent::class], replaces = [UserDataStoreModule::class]) +class TestUserDataStoreModule { + + @Provides + @Singleton + public fun provideUserDataStore( + @UserDataStoreQualifier + dataStore: DataStore, + testDispatcher: TestDispatcher, + ): UserDataStore { + return UserDataStore( + dataStore = dataStore, + coroutineScope = CoroutineScope(testDispatcher + Job()), + ) + } +}