From cf0a3d732e5612d9fa5616c546a50802176351f9 Mon Sep 17 00:00:00 2001 From: Peter Chege <62762943+chege4179@users.noreply.github.com> Date: Wed, 23 Aug 2023 11:21:30 +0300 Subject: [PATCH] Sessions offline first capabilities and app sync at startup time (#160) * Migrate state in speakers screen and speakerdetails screen to viewmodel * Update tests * Requested changes * Sessions offline first features * Fix linting errors * Fix failing tests * Made requested changes * Fix error due to merge conflict * Removed comments and fixed failing test --- .../droidconKE2023/app/DroidconKE2023App.kt | 20 +++ .../com/android254/data/dao/BookmarkDao.kt | 3 +- .../com/android254/data/dao/SessionDao.kt | 6 +- .../android254/data/di/RemoteConfigModule.kt | 3 +- .../data/network/util/RemoteConfigConfig.kt | 2 +- .../data/network/util/RemoteFeatureToggle.kt | 2 +- .../android254/data/repos/SessionsManager.kt | 127 ++++++---------- .../repos/local/LocalSessionsDataSource.kt | 4 +- .../local/LocalSessionsDataSourceImpl.kt | 15 +- .../data/work/SyncDataWorkManagerImpl.kt | 5 +- .../android254/data/work/SyncDataWorker.kt | 15 ++ .../com/android254/data/work/WorkConstants.kt | 22 +++ .../android254/data/work/WorkInitializer.kt | 47 ++++++ data/src/main/res/values/strings.xml | 4 + .../data/repos/SessionsManagerTest.kt | 49 +++--- .../android254/domain/repos/SessionsRepo.kt | 17 ++- .../common/components/SessionsCard.kt | 24 +-- .../sessionDetails/SessionDetailsViewModel.kt | 63 +++++--- .../view/SessionDetailsScreen.kt | 78 +++++++--- .../components/SessionStateComponent.kt | 5 +- .../sessions/mappers/SessionMapper.kt | 2 +- .../sessions/view/SessionsScreen.kt | 6 +- .../sessions/view/SessionsViewModel.kt | 142 ++++++++++-------- .../view/SessionDetailsScreenTest.kt | 64 ++++++-- .../sessions/view/FakeSyncWorkManager.kt | 27 ++++ .../sessions/view/SessionScreenTest.kt | 91 +++++++++-- 26 files changed, 558 insertions(+), 285 deletions(-) create mode 100644 data/src/main/java/com/android254/data/work/WorkConstants.kt create mode 100644 data/src/main/java/com/android254/data/work/WorkInitializer.kt create mode 100644 data/src/main/res/values/strings.xml create mode 100644 presentation/src/test/java/com/android254/presentation/sessions/view/FakeSyncWorkManager.kt diff --git a/app/src/main/java/com/android254/droidconKE2023/app/DroidconKE2023App.kt b/app/src/main/java/com/android254/droidconKE2023/app/DroidconKE2023App.kt index 1c4699de..b9d3ed47 100644 --- a/app/src/main/java/com/android254/droidconKE2023/app/DroidconKE2023App.kt +++ b/app/src/main/java/com/android254/droidconKE2023/app/DroidconKE2023App.kt @@ -16,9 +16,15 @@ package com.android254.droidconKE2023.app import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration +import androidx.work.WorkManager import com.android254.data.network.util.RemoteFeatureToggle +import com.android254.data.work.WorkConstants +import com.android254.data.work.WorkInitializer import com.android254.droidconKE2023.crashlytics.CrashlyticsTree import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @@ -37,6 +43,8 @@ class DroidconKE2023App : Application(), Configuration.Provider { super.onCreate() remoteFeatureToggle.sync() initTimber() + setUpWorkerManagerNotificationChannel() + WorkInitializer.initialize(context = this) } override fun getWorkManagerConfiguration(): Configuration = @@ -58,4 +66,16 @@ class DroidconKE2023App : Application(), Configuration.Provider { Timber.plant(CrashlyticsTree()) } } + private fun setUpWorkerManagerNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + WorkConstants.NOTIFICATION_CHANNEL, + WorkConstants.syncDataWorkerName, + NotificationManager.IMPORTANCE_HIGH + ) + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + WorkManager.initialize(this, Configuration.Builder().setWorkerFactory(workerFactory).build()) + } } \ No newline at end of file diff --git a/data/src/main/java/com/android254/data/dao/BookmarkDao.kt b/data/src/main/java/com/android254/data/dao/BookmarkDao.kt index d92bb14b..03ce3380 100644 --- a/data/src/main/java/com/android254/data/dao/BookmarkDao.kt +++ b/data/src/main/java/com/android254/data/dao/BookmarkDao.kt @@ -18,9 +18,10 @@ package com.android254.data.dao import androidx.room.Dao import androidx.room.Query import com.android254.data.db.model.BookmarkEntity +import kotlinx.coroutines.flow.Flow @Dao interface BookmarkDao : BaseDao { @Query("SELECT * FROM bookmarks") - fun getBookmarkIds(): List + fun getBookmarkIds(): Flow> } \ No newline at end of file diff --git a/data/src/main/java/com/android254/data/dao/SessionDao.kt b/data/src/main/java/com/android254/data/dao/SessionDao.kt index d739a202..f895f352 100644 --- a/data/src/main/java/com/android254/data/dao/SessionDao.kt +++ b/data/src/main/java/com/android254/data/dao/SessionDao.kt @@ -31,10 +31,10 @@ interface SessionDao : BaseDao { suspend fun clearSessions() @Query("SELECT * FROM sessions WHERE id = :id") - suspend fun getSessionById(id: String): SessionEntity? + fun getSessionById(id: String): Flow - @RawQuery - suspend fun fetchSessionsWithFilters(query: SupportSQLiteQuery): List + @RawQuery(observedEntities = arrayOf(SessionEntity::class)) + fun fetchSessionsWithFilters(query: SupportSQLiteQuery): Flow> @Query("UPDATE sessions SET isBookmarked = :isBookmarked WHERE remote_id = :id") suspend fun updateBookmarkedStatus(id: String, isBookmarked: Boolean) diff --git a/data/src/main/java/com/android254/data/di/RemoteConfigModule.kt b/data/src/main/java/com/android254/data/di/RemoteConfigModule.kt index e4513be5..231ae27c 100644 --- a/data/src/main/java/com/android254/data/di/RemoteConfigModule.kt +++ b/data/src/main/java/com/android254/data/di/RemoteConfigModule.kt @@ -38,5 +38,4 @@ object RemoteConfigModule { @Provides @Singleton fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig = RemoteConfigConfig.setup() - -} +} \ No newline at end of file diff --git a/data/src/main/java/com/android254/data/network/util/RemoteConfigConfig.kt b/data/src/main/java/com/android254/data/network/util/RemoteConfigConfig.kt index d0f422e2..a2aca260 100644 --- a/data/src/main/java/com/android254/data/network/util/RemoteConfigConfig.kt +++ b/data/src/main/java/com/android254/data/network/util/RemoteConfigConfig.kt @@ -37,4 +37,4 @@ object RemoteConfigConfig { } setConfigSettingsAsync(configSettings) } -} +} \ No newline at end of file diff --git a/data/src/main/java/com/android254/data/network/util/RemoteFeatureToggle.kt b/data/src/main/java/com/android254/data/network/util/RemoteFeatureToggle.kt index 757f5295..2db2bc2d 100644 --- a/data/src/main/java/com/android254/data/network/util/RemoteFeatureToggle.kt +++ b/data/src/main/java/com/android254/data/network/util/RemoteFeatureToggle.kt @@ -34,4 +34,4 @@ class RemoteFeatureToggle( } fun getString(key: String): String = remoteConfig.getString(key) -} +} \ No newline at end of file diff --git a/data/src/main/java/com/android254/data/repos/SessionsManager.kt b/data/src/main/java/com/android254/data/repos/SessionsManager.kt index b05e97cb..46ff37ee 100644 --- a/data/src/main/java/com/android254/data/repos/SessionsManager.kt +++ b/data/src/main/java/com/android254/data/repos/SessionsManager.kt @@ -15,22 +15,20 @@ */ package com.android254.data.repos -import android.os.Build -import androidx.annotation.RequiresApi import androidx.sqlite.db.SimpleSQLiteQuery import com.android254.data.dao.BookmarkDao import com.android254.data.dao.SessionDao import com.android254.data.db.model.BookmarkEntity import com.android254.data.di.IoDispatcher import com.android254.data.network.apis.SessionsApi -import com.android254.data.network.util.NetworkError import com.android254.data.repos.mappers.toDomainModel -import com.android254.data.repos.mappers.toEntity -import com.android254.domain.models.ResourceResult import com.android254.domain.models.Session import com.android254.domain.repos.SessionsRepo import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import javax.inject.Inject @@ -40,87 +38,60 @@ class SessionsManager @Inject constructor( private val bookmarkDao: BookmarkDao, @IoDispatcher private val ioDispatcher: CoroutineDispatcher ) : SessionsRepo { - @RequiresApi(Build.VERSION_CODES.O) - override suspend fun fetchAndSaveSessions( - fetchFromRemote: Boolean, - query: String? - ): ResourceResult> = withContext(ioDispatcher) { - val sessions = if (query == null) { - dao.fetchSessions().first() - } else { - dao.fetchSessionsWithFilters(SimpleSQLiteQuery(query)) - } - val isDbEmpty = sessions.isEmpty() - val hasAQuery = query != null - val shouldLoadFromCache = (!isDbEmpty && !fetchFromRemote) || hasAQuery - if (shouldLoadFromCache) { - return@withContext try { - val response = api.fetchSessions() - val remoteSessions = response.data.flatMap { (_, value) -> value } - if (remoteSessions.isEmpty()) { - ResourceResult.Empty("No sessions just yet") - } else { - remoteSessions.let { - dao.clearSessions() - val bookmarkIds = bookmarkDao.getBookmarkIds().map { sessionEntity -> - sessionEntity.sessionId - } - val sessionEntities = it.map { session -> - val newSession = session.toEntity().copy( - isBookmarked = bookmarkIds.contains(session.id) - ) - newSession - } - dao.insert(sessionEntities) - ResourceResult.Success( - data = sessionEntities.map { sessionEntity -> sessionEntity.toDomainModel() } - ) - } - } - } catch (e: Exception) { - when (e) { - is NetworkError -> ResourceResult.Error("Network error") - else -> ResourceResult.Error("Error fetching sessions") + override fun fetchSessions(): Flow> { + val bookmarksFlow = bookmarkDao.getBookmarkIds() + val sessionsFlow = dao.fetchSessions() + return combine(sessionsFlow, bookmarksFlow) { sessions, bookmarks -> + sessions.map { it.toDomainModel() } + .map { session -> + session.copy(isBookmarked = bookmarks.map { it.sessionId }.contains(session.id)) } - } - } else { - return@withContext ResourceResult.Success( - data = sessions.map { - it.toDomainModel() + }.flowOn(ioDispatcher) + } + override fun fetchBookmarkedSessions(): Flow> { + val bookmarksFlow = bookmarkDao.getBookmarkIds() + val sessionsFlow = dao.fetchSessions() + return combine(sessionsFlow, bookmarksFlow) { sessions, bookmarks -> + sessions.map { it.toDomainModel() } + .map { session -> + session.copy(isBookmarked = bookmarks.map { it.sessionId }.contains(session.id)) } - ) + .filter { session -> session.isBookmarked } + }.flowOn(ioDispatcher) + } + + override fun fetchFilteredSessions(query: String): Flow> { + val filteredSessions = dao.fetchSessionsWithFilters(SimpleSQLiteQuery(query)) + val bookmarksFlow = bookmarkDao.getBookmarkIds() + return combine(filteredSessions, bookmarksFlow) { sessions, bookmarks -> + sessions.map { session -> + session.copy( + isBookmarked = bookmarks.map { it.sessionId }.contains(session.id.toString()) + ) + }.map { it.toDomainModel() } + }.flowOn(ioDispatcher) + } + + override fun fetchSessionById(sessionId: String): Flow { + val bookmarksFlow = bookmarkDao.getBookmarkIds() + val sessionFlow = dao.getSessionById(sessionId).map { + it?.toDomainModel() } + return combine(sessionFlow, bookmarksFlow) { session, bookmarks -> + session?.copy(isBookmarked = bookmarks.map { it.sessionId }.contains(session.id)) + }.flowOn(ioDispatcher) } - override suspend fun fetchSessionById(id: String): ResourceResult = + override suspend fun bookmarkSession(id: String) { withContext(ioDispatcher) { - val session = dao.getSessionById(id) - ?: return@withContext ResourceResult.Error(message = "requested event no longer available") - return@withContext ResourceResult.Success(data = session.toDomainModel()) + bookmarkDao.insert(BookmarkEntity(id)) } + } - override suspend fun toggleBookmarkStatus( - id: String, - isCurrentlyStarred: Boolean - ): ResourceResult = withContext(ioDispatcher) { - try { - dao.updateBookmarkedStatus(id, !isCurrentlyStarred) - if (isCurrentlyStarred) { - bookmarkDao.delete(BookmarkEntity(id)) - } else { - bookmarkDao.insert(BookmarkEntity(id)) - } - } catch (e: Exception) { - when (e) { - is NetworkError -> { - return@withContext ResourceResult.Error("Network error") - } - else -> { - return@withContext ResourceResult.Error("Error fetching sessions") - } - } + override suspend fun unBookmarkSession(id: String) { + withContext(ioDispatcher) { + bookmarkDao.delete(BookmarkEntity(id)) } - return@withContext ResourceResult.Success(data = dao.getBookmarkStatus(id)) } } \ No newline at end of file diff --git a/data/src/main/java/com/android254/data/repos/local/LocalSessionsDataSource.kt b/data/src/main/java/com/android254/data/repos/local/LocalSessionsDataSource.kt index 24ef0396..ed82c8ff 100644 --- a/data/src/main/java/com/android254/data/repos/local/LocalSessionsDataSource.kt +++ b/data/src/main/java/com/android254/data/repos/local/LocalSessionsDataSource.kt @@ -27,9 +27,9 @@ interface LocalSessionsDataSource { suspend fun deleteCachedSessions() - suspend fun getCachedSessionById(id: String): SessionEntity? + fun getCachedSessionById(id: String): Flow - suspend fun fetchSessionWithFilters(query: SupportSQLiteQuery): List + fun fetchSessionWithFilters(query: SupportSQLiteQuery): Flow> suspend fun updateBookmarkedStatus(id: String, isBookmarked: Boolean) diff --git a/data/src/main/java/com/android254/data/repos/local/LocalSessionsDataSourceImpl.kt b/data/src/main/java/com/android254/data/repos/local/LocalSessionsDataSourceImpl.kt index 0c557601..f9aad760 100644 --- a/data/src/main/java/com/android254/data/repos/local/LocalSessionsDataSourceImpl.kt +++ b/data/src/main/java/com/android254/data/repos/local/LocalSessionsDataSourceImpl.kt @@ -52,17 +52,12 @@ class LocalSessionsDataSourceImpl @Inject constructor( } } - override suspend fun getCachedSessionById(id: String): SessionEntity? { - return withContext(ioDispatcher) { - sessionDao.getSessionById(id = id) - } - } + override fun getCachedSessionById(id: String): Flow = + sessionDao.getSessionById(id = id).flowOn(ioDispatcher) - override suspend fun fetchSessionWithFilters(query: SupportSQLiteQuery): List { - return withContext(ioDispatcher) { - sessionDao.fetchSessionsWithFilters(query = query).map { it.toDomainModel() } - } - } + override fun fetchSessionWithFilters(query: SupportSQLiteQuery): Flow> = + sessionDao.fetchSessionsWithFilters(query = query).map { it.map { it.toDomainModel() } } + .flowOn(ioDispatcher) override suspend fun updateBookmarkedStatus(id: String, isBookmarked: Boolean) { withContext(ioDispatcher) { diff --git a/data/src/main/java/com/android254/data/work/SyncDataWorkManagerImpl.kt b/data/src/main/java/com/android254/data/work/SyncDataWorkManagerImpl.kt index b05ba903..c35312e1 100644 --- a/data/src/main/java/com/android254/data/work/SyncDataWorkManagerImpl.kt +++ b/data/src/main/java/com/android254/data/work/SyncDataWorkManagerImpl.kt @@ -24,6 +24,7 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager +import com.android254.data.work.WorkConstants.syncDataWorkerName import com.android254.domain.work.SyncDataWorkManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -53,10 +54,6 @@ class SyncDataWorkManagerImpl @Inject constructor( workManager.beginUniqueWork(syncDataWorkerName, ExistingWorkPolicy.KEEP, syncDataRequest) .enqueue() } - - companion object { - const val syncDataWorkerName = "sync_data" - } } val List.anyRunning get() = any { it.state == WorkInfo.State.RUNNING } \ No newline at end of file diff --git a/data/src/main/java/com/android254/data/work/SyncDataWorker.kt b/data/src/main/java/com/android254/data/work/SyncDataWorker.kt index 4ee0b898..8cb68859 100644 --- a/data/src/main/java/com/android254/data/work/SyncDataWorker.kt +++ b/data/src/main/java/com/android254/data/work/SyncDataWorker.kt @@ -16,8 +16,10 @@ package com.android254.data.work import android.content.Context +import androidx.core.app.NotificationCompat import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.android254.data.di.IoDispatcher import com.android254.data.repos.local.LocalSessionsDataSource @@ -29,10 +31,12 @@ import com.android254.data.repos.remote.RemoteSponsorsDataSource import com.android254.domain.models.ResourceResult import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import ke.droidcon.kotlin.data.R import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.withContext import timber.log.Timber +import kotlin.random.Random @HiltWorker class SyncDataWorker @AssistedInject constructor( @@ -49,6 +53,17 @@ class SyncDataWorker @AssistedInject constructor( ) : CoroutineWorker(appContext, workerParameters) { + override suspend fun getForegroundInfo(): ForegroundInfo { + return ForegroundInfo( + Random.nextInt(), + NotificationCompat.Builder(appContext, WorkConstants.NOTIFICATION_CHANNEL) + .setSmallIcon(androidx.core.R.drawable.notification_bg_low) + .setContentTitle(R.string.sync_notification_message.toString()) + .build() + + ) + } + override suspend fun doWork(): Result { withContext(ioDispatcher) { val sessionSyncDefferred = async { syncSessions() } diff --git a/data/src/main/java/com/android254/data/work/WorkConstants.kt b/data/src/main/java/com/android254/data/work/WorkConstants.kt new file mode 100644 index 00000000..c2e500e8 --- /dev/null +++ b/data/src/main/java/com/android254/data/work/WorkConstants.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android254.data.work + +object WorkConstants { + + const val NOTIFICATION_CHANNEL = "notification_channel" + const val syncDataWorkerName = "sync_data" +} \ No newline at end of file diff --git a/data/src/main/java/com/android254/data/work/WorkInitializer.kt b/data/src/main/java/com/android254/data/work/WorkInitializer.kt new file mode 100644 index 00000000..923ff278 --- /dev/null +++ b/data/src/main/java/com/android254/data/work/WorkInitializer.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android254.data.work + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import com.android254.data.work.WorkConstants.syncDataWorkerName + +object WorkInitializer { + fun initialize(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType( + NetworkType.CONNECTED + ) + .build() + ) + .build() + WorkManager.getInstance(context).apply { + enqueueUniqueWork( + syncDataWorkerName, + ExistingWorkPolicy.KEEP, + request + ) + } + } +} \ No newline at end of file diff --git a/data/src/main/res/values/strings.xml b/data/src/main/res/values/strings.xml new file mode 100644 index 00000000..fecc1cf2 --- /dev/null +++ b/data/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Syncing App Data h + \ No newline at end of file diff --git a/data/src/test/java/com/android254/data/repos/SessionsManagerTest.kt b/data/src/test/java/com/android254/data/repos/SessionsManagerTest.kt index 97fb4d09..191619cb 100644 --- a/data/src/test/java/com/android254/data/repos/SessionsManagerTest.kt +++ b/data/src/test/java/com/android254/data/repos/SessionsManagerTest.kt @@ -21,21 +21,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android254.data.dao.BookmarkDao import com.android254.data.dao.SessionDao import com.android254.data.db.Database +import com.android254.data.db.model.SessionEntity import com.android254.data.network.apis.SessionsApi -// import com.android254.domain.models.DataResult -// import com.android254.domain.models.Success -import io.mockk.coEvery -// import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first -// import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -// import org.hamcrest.CoreMatchers -// import org.hamcrest.MatcherAssert.assertThat +import kotlinx.coroutines.test.runTest import org.junit.After -import org.junit.Assert import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -60,22 +53,36 @@ class SessionsManagerTest { } @Test - fun `test it fetches and saves sessions successfully`() { + fun `test it fetches from the local cache`() = runTest { val repo = SessionsManager(mockApi, sessionDao, bookmarkDao, ioDispatcher) - - runBlocking { - val session = sessionDao.fetchSessions() - Assert.assertEquals(session.first().isEmpty(), true) - coEvery { mockApi.fetchSessions() } returns results - val result = repo.fetchAndSaveSessions() -// coVerify { mockApi.fetchSessions() } -// assertThat(result, CoreMatchers.`is`(DataResult.Success(Success))) -// Assert.assertEquals(dao.fetchSessions().count(), 1) - } + sessionDao.insert(listOf(sessionEntity)) + val sessions = repo.fetchSessions().first() + assert(sessions[0].description == sessionEntity.description) } @After fun afterTest() { database.close() } -} \ No newline at end of file +} +val sessionEntity = SessionEntity( + id = 1, + remote_id = "1234567890", + description = "This is a keynote session about the future of technology.", + sessionFormat = "Keynote", + sessionLevel = "Beginner", + slug = "keynote-session", + title = "The Future of Technology", + endDateTime = "2023-08-17T12:00:00Z", + endTime = "12:00 PM", + isBookmarked = false, + isKeynote = true, + isServiceSession = false, + sessionImage = "https://example.com/session-1.jpg", + startDateTime = "2023-08-17T10:00:00Z", + startTime = "10:00 AM", + rooms = "Room 1", + speakers = "John Doe, Jane Doe", + startTimestamp = 1638457600000, + sessionImageUrl = "https://example.com/session-1.jpg" +) \ No newline at end of file diff --git a/domain/src/main/java/com/android254/domain/repos/SessionsRepo.kt b/domain/src/main/java/com/android254/domain/repos/SessionsRepo.kt index 2b2c96db..7fd8a72e 100644 --- a/domain/src/main/java/com/android254/domain/repos/SessionsRepo.kt +++ b/domain/src/main/java/com/android254/domain/repos/SessionsRepo.kt @@ -15,16 +15,19 @@ */ package com.android254.domain.repos -import com.android254.domain.models.ResourceResult import com.android254.domain.models.Session +import kotlinx.coroutines.flow.Flow interface SessionsRepo { - suspend fun fetchAndSaveSessions( - fetchFromRemote: Boolean = false, - query: String? = null - ): ResourceResult> + fun fetchSessions(): Flow> - suspend fun fetchSessionById(id: String): ResourceResult + fun fetchFilteredSessions(query: String): Flow> - suspend fun toggleBookmarkStatus(id: String, isCurrentlyStarred: Boolean): ResourceResult + fun fetchBookmarkedSessions(): Flow> + + fun fetchSessionById(id: String): Flow + + suspend fun bookmarkSession(id: String) + + suspend fun unBookmarkSession(id: String) } \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/common/components/SessionsCard.kt b/presentation/src/main/java/com/android254/presentation/common/components/SessionsCard.kt index cfac6cff..745454f7 100644 --- a/presentation/src/main/java/com/android254/presentation/common/components/SessionsCard.kt +++ b/presentation/src/main/java/com/android254/presentation/common/components/SessionsCard.kt @@ -38,9 +38,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,7 +50,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage -import com.android254.domain.models.ResourceResult import com.android254.presentation.models.SessionPresentationModel import com.android254.presentation.sessions.view.SessionsViewModel import com.droidconke.chai.atoms.MontserratBold @@ -164,9 +161,6 @@ fun SessionTitleComponent( session: SessionPresentationModel, viewModel: SessionsViewModel = hiltViewModel() ) { - val isStarred = rememberSaveable() { - mutableStateOf(session.isStarred) - } val scope = rememberCoroutineScope() Row( Modifier @@ -185,23 +179,15 @@ fun SessionTitleComponent( ) IconButton(onClick = { scope.launch { - when ( - val result = - viewModel.updateBookmarkStatus(session.remoteId, isStarred.value) - ) { - is ResourceResult.Empty -> {} - is ResourceResult.Error -> { - } - is ResourceResult.Loading -> { - } - is ResourceResult.Success -> { - isStarred.value = if (result.data != null) result.data!! else false - } + if (session.isStarred) { + viewModel.unBookmarkSession(session.id) + } else { + viewModel.bookmarkSession(session.id) } } }) { Icon( - imageVector = if (isStarred.value) Icons.Rounded.Star else Icons.Rounded.StarOutline, + imageVector = if (session.isStarred) Icons.Rounded.Star else Icons.Rounded.StarOutline, contentDescription = stringResource(R.string.star_session_icon_description), tint = MaterialTheme.colorScheme.primary ) diff --git a/presentation/src/main/java/com/android254/presentation/sessionDetails/SessionDetailsViewModel.kt b/presentation/src/main/java/com/android254/presentation/sessionDetails/SessionDetailsViewModel.kt index ccdf295f..1c55d0e8 100644 --- a/presentation/src/main/java/com/android254/presentation/sessionDetails/SessionDetailsViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/sessionDetails/SessionDetailsViewModel.kt @@ -15,45 +15,60 @@ */ package com.android254.presentation.sessionDetails +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.android254.domain.models.ResourceResult import com.android254.domain.repos.SessionsRepo +import com.android254.presentation.common.navigation.Screens import com.android254.presentation.models.SessionDetailsPresentationModel import com.android254.presentation.sessions.mappers.toSessionDetailsPresentationModal import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject -@HiltViewModel -class SessionDetailsViewModel @Inject constructor( - private val sessionsRepo: SessionsRepo -) : ViewModel() { - private val _sessionDetails = MutableStateFlow(null) - val sessionDetails = _sessionDetails.asStateFlow() +sealed interface SessionDetailsUiState { - fun getSessionDetailsById(sessionId: String) { - viewModelScope.launch { - when (val result = sessionsRepo.fetchSessionById(sessionId)) { - is ResourceResult.Success -> { - result.data.let { - _sessionDetails.value = it?.toSessionDetailsPresentationModal() - } - } + object Loading : SessionDetailsUiState + data class Success(val data: SessionDetailsPresentationModel) : SessionDetailsUiState - is ResourceResult.Error -> { - } + data class Error(val message: String) : SessionDetailsUiState +} + +@HiltViewModel +class SessionDetailsViewModel @Inject constructor( + private val sessionsRepo: SessionsRepo, + private val savedStateHandle: SavedStateHandle - is ResourceResult.Loading -> { - } +) : ViewModel() { - is ResourceResult.Empty -> { - } + private val sessionId = savedStateHandle.get(Screens.SessionDetails.sessionIdNavigationArgument) - else -> Unit + val uiState = sessionsRepo.fetchSessionById(id = sessionId ?: "") + .map { + if (it == null) { + SessionDetailsUiState.Error(message = "Session Info not found") + } else { + SessionDetailsUiState.Success(it.toSessionDetailsPresentationModal()) } } + .onStart { SessionDetailsUiState.Loading } + .catch { SessionDetailsUiState.Error(message = "An unexpected error occurred") } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = SessionDetailsUiState.Loading + ) + + fun bookmarkSession(sessionId: String) = viewModelScope.launch { + sessionsRepo.bookmarkSession(sessionId) + } + + fun unBookmarkSession(sessionId: String) = viewModelScope.launch { + sessionsRepo.unBookmarkSession(sessionId) } } \ No newline at end of file diff --git a/presentation/src/main/java/com/android254/presentation/sessionDetails/view/SessionDetailsScreen.kt b/presentation/src/main/java/com/android254/presentation/sessionDetails/view/SessionDetailsScreen.kt index 977967b5..408872f4 100644 --- a/presentation/src/main/java/com/android254/presentation/sessionDetails/view/SessionDetailsScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/sessionDetails/view/SessionDetailsScreen.kt @@ -50,6 +50,7 @@ import coil.compose.AsyncImage import com.android254.presentation.common.theme.DroidconKE2023Theme import com.android254.presentation.common.theme.Montserrat import com.android254.presentation.models.SessionDetailsPresentationModel +import com.android254.presentation.sessionDetails.SessionDetailsUiState import com.android254.presentation.sessionDetails.SessionDetailsViewModel import com.droidconke.chai.components.COutlinedButton import ke.droidcon.kotlin.presentation.R @@ -61,11 +62,7 @@ fun SessionDetailsScreen( sessionId: String, onNavigationIconClick: () -> Unit ) { - val sessionDetails = viewModel.sessionDetails.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = sessionId) { - viewModel.getSessionDetailsById(sessionId) - } + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value Scaffold( topBar = { TopBar(onNavigationIconClick) }, @@ -87,19 +84,44 @@ fun SessionDetailsScreen( } } ) { paddingValues -> - sessionDetails.value.let { - if (it != null) { - Body(paddingValues, darkTheme, it) + when (uiState) { + is SessionDetailsUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + is SessionDetailsUiState.Error -> { + Box( + modifier = Modifier.fillMaxSize() + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = uiState.message + ) + } + } + is SessionDetailsUiState.Success -> { + Body( + paddingValues = paddingValues, + darkTheme = darkTheme, + sessionDetails = uiState.data, + bookmarkSession = viewModel::bookmarkSession, + unBookmarkSession = viewModel::unBookmarkSession + ) } } } } @Composable -private fun Body( +fun Body( paddingValues: PaddingValues, darkTheme: Boolean, - sessionDetails: SessionDetailsPresentationModel + sessionDetails: SessionDetailsPresentationModel, + bookmarkSession: (String) -> Unit, + unBookmarkSession: (String) -> Unit ) { Column( modifier = Modifier @@ -113,7 +135,12 @@ private fun Body( Column(modifier = Modifier.padding(start = 18.dp, end = 18.dp)) { Spacer(modifier = Modifier.height(24.dp)) - SessionSpeakerNameAndFavouriteIcon(darkTheme, sessionDetails) + SessionSpeakerNameAndFavouriteIcon( + darkTheme = darkTheme, + sessionDetails = sessionDetails, + bookmarkSession = bookmarkSession, + unBookmarkSession = unBookmarkSession + ) Spacer(modifier = Modifier.height(25.dp)) @@ -224,7 +251,9 @@ private fun SessionBannerImage(sessionDetails: SessionDetailsPresentationModel) @Composable private fun SessionSpeakerNameAndFavouriteIcon( darkTheme: Boolean, - sessionDetails: SessionDetailsPresentationModel + sessionDetails: SessionDetailsPresentationModel, + bookmarkSession: (String) -> Unit, + unBookmarkSession: (String) -> Unit ) { Row( modifier = Modifier.fillMaxWidth(), @@ -268,15 +297,22 @@ private fun SessionSpeakerNameAndFavouriteIcon( lineHeight = 18.sp ) ) - - Icon( - imageVector = if (sessionDetails.isStarred) Icons.Rounded.Star else Icons.Rounded.StarOutline, - contentDescription = null, - modifier = Modifier - .size(21.dp) - .testTag(TestTag.FAVOURITE_ICON), - tint = colorResource(id = if (darkTheme) R.color.cyan else R.color.blue) - ) + IconButton(onClick = { + if (sessionDetails.isStarred) { + unBookmarkSession(sessionDetails.id) + } else { + bookmarkSession(sessionDetails.id) + } + }) { + Icon( + imageVector = if (sessionDetails.isStarred) Icons.Rounded.Star else Icons.Rounded.StarOutline, + contentDescription = null, + modifier = Modifier + .size(21.dp) + .testTag(TestTag.FAVOURITE_ICON), + tint = colorResource(id = if (darkTheme) R.color.cyan else R.color.blue) + ) + } } } diff --git a/presentation/src/main/java/com/android254/presentation/sessions/components/SessionStateComponent.kt b/presentation/src/main/java/com/android254/presentation/sessions/components/SessionStateComponent.kt index 0cc47b50..706805d4 100644 --- a/presentation/src/main/java/com/android254/presentation/sessions/components/SessionStateComponent.kt +++ b/presentation/src/main/java/com/android254/presentation/sessions/components/SessionStateComponent.kt @@ -46,9 +46,10 @@ fun SessionsStateComponent( sessionsUiState: SessionsUiState, navigateToSessionDetails: (sessionId: String) -> Unit, refreshSessionsList: () -> Unit, - retry: () -> Unit + retry: () -> Unit, + isRefreshing: Boolean ) { - val swipeRefreshState = rememberSwipeRefreshState(false) + val swipeRefreshState = rememberSwipeRefreshState(isRefreshing = isRefreshing) when (sessionsUiState) { is SessionsUiState.Loading -> { SessionsLoadingSkeleton() diff --git a/presentation/src/main/java/com/android254/presentation/sessions/mappers/SessionMapper.kt b/presentation/src/main/java/com/android254/presentation/sessions/mappers/SessionMapper.kt index 8c0a04b1..ea1ea3a4 100644 --- a/presentation/src/main/java/com/android254/presentation/sessions/mappers/SessionMapper.kt +++ b/presentation/src/main/java/com/android254/presentation/sessions/mappers/SessionMapper.kt @@ -68,7 +68,7 @@ fun Session.toSessionDetailsPresentationModal(): SessionDetailsPresentationModel startTime = startTime.time, endTime = this.endTime, amOrPm = startTime.period, - isStarred = false, + isStarred = this.isBookmarked, level = this.sessionLevel, format = this.sessionFormat, sessionImageUrl = this.sessionImage.toString(), diff --git a/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsScreen.kt b/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsScreen.kt index 94a2b96e..711eca6b 100644 --- a/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsScreen.kt +++ b/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsScreen.kt @@ -70,6 +70,7 @@ fun SessionsScreen( sessionsViewModel: SessionsViewModel = hiltViewModel(), navigateToSessionDetails: (sessionId: String) -> Unit = {} ) { + val isRefreshing = sessionsViewModel.isRefreshing.collectAsStateWithLifecycle() val sessionsUiState = sessionsViewModel.sessionsUiState.collectAsStateWithLifecycle().value val showMySessions = remember { mutableStateOf(false) @@ -136,7 +137,7 @@ fun SessionsScreen( showMySessions.value = it isFilterActive.value = !it if (showMySessions.value) { - sessionsViewModel.fetchBookmarkedSessions() + sessionsViewModel.toggleBookmarkFilter() } else { sessionsViewModel.clearSelectedFilterList() } @@ -146,7 +147,8 @@ fun SessionsScreen( sessionsUiState = sessionsUiState, navigateToSessionDetails = navigateToSessionDetails, refreshSessionsList = { sessionsViewModel.refreshSessionList() }, - retry = { } + retry = { }, + isRefreshing = isRefreshing.value ) if (bottomSheetState.isVisible) { ModalBottomSheet( diff --git a/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsViewModel.kt b/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsViewModel.kt index 8d63a37d..86073593 100644 --- a/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsViewModel.kt +++ b/presentation/src/main/java/com/android254/presentation/sessions/view/SessionsViewModel.kt @@ -17,8 +17,8 @@ package com.android254.presentation.sessions.view import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.android254.domain.models.ResourceResult import com.android254.domain.repos.SessionsRepo +import com.android254.domain.work.SyncDataWorkManager import com.android254.presentation.models.EventDate import com.android254.presentation.models.SessionsFilterOption import com.android254.presentation.sessions.mappers.toPresentationModel @@ -26,7 +26,10 @@ import com.android254.presentation.sessions.models.SessionsUiState import com.android254.presentation.sessions.utils.SessionsFilterCategory import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import timber.log.Timber @@ -34,7 +37,9 @@ import javax.inject.Inject @HiltViewModel class SessionsViewModel @Inject constructor( - private val sessionsRepo: SessionsRepo + private val sessionsRepo: SessionsRepo, + private val syncDataWorkManager: SyncDataWorkManager + ) : ViewModel() { private val _selectedFilterOptions: MutableStateFlow> = @@ -51,9 +56,16 @@ class SessionsViewModel @Inject constructor( private val _sessionsUiState = MutableStateFlow(SessionsUiState.Idle) val sessionsUiState = _sessionsUiState.asStateFlow() + + val isRefreshing = syncDataWorkManager.isSyncing + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = false + ) init { viewModelScope.launch { - fetchSessions(fetchFromRemote = false) + fetchAllSessions() } } @@ -130,63 +142,57 @@ class SessionsViewModel @Inject constructor( } } - private suspend fun fetchSessions(query: String? = null, fetchFromRemote: Boolean = false) { - val result = - sessionsRepo.fetchAndSaveSessions(query = query, fetchFromRemote = fetchFromRemote) - when (result) { - is ResourceResult.Success -> { - result.data.let { sessionDomainModels -> - val sessions = sessionDomainModels?.map { sessionDomainModel -> - sessionDomainModel.toPresentationModel() - } - - _sessionsUiState.value = if (!sessions.isNullOrEmpty()) { - SessionsUiState.Data(data = sessions) - } else { - SessionsUiState.Empty("No sessions Found") - } + private suspend fun fetchAllSessions() { + _sessionsUiState.value = SessionsUiState.Loading + sessionsRepo.fetchSessions().collectLatest { sessions -> + try { + _sessionsUiState.value = if (sessions.isNotEmpty()) { + SessionsUiState.Data(data = sessions.map { it.toPresentationModel() }) + } else { + SessionsUiState.Empty("No sessions Found") } + } catch (e: Exception) { + _sessionsUiState.value = SessionsUiState.Error( + message = e.message ?: "An unexpected error occurred" + ) } + } + } - is ResourceResult.Error -> { - _sessionsUiState.value = SessionsUiState.Error(message = result.message) - } - - is ResourceResult.Loading -> { - _sessionsUiState.value = SessionsUiState.Loading + private suspend fun fetchFilteredSessions(query: String) { + _sessionsUiState.value = SessionsUiState.Loading + sessionsRepo.fetchFilteredSessions(query = query).collectLatest { sessions -> + try { + _sessionsUiState.value = if (sessions.isNotEmpty()) { + SessionsUiState.Data(data = sessions.map { it.toPresentationModel() }) + } else { + SessionsUiState.Empty("No sessions Found") + } + } catch (e: Exception) { + _sessionsUiState.value = SessionsUiState.Error( + message = + e.message ?: "Sessions not found" + ) } + } + } - is ResourceResult.Empty -> { - _sessionsUiState.value = SessionsUiState.Empty("No sessions Found") + private suspend fun fetchBookmarkSessions() { + _sessionsUiState.value = SessionsUiState.Loading + sessionsRepo.fetchBookmarkedSessions().collectLatest { sessions -> + try { + _sessionsUiState.value = if (sessions.isNotEmpty()) { + SessionsUiState.Data(data = sessions.map { it.toPresentationModel() }) + } else { + SessionsUiState.Empty("No bookmarked sessions Found") + } + } catch (e: Exception) { + _sessionsUiState.value = SessionsUiState.Error( + message = + e.message ?: "Bookmark sessions not found" + ) } - - else -> Unit } - - // var query = Query().fields("*").from("sessions") -// if (_filterState.value!!.rooms.isNotEmpty()) { -// val rooms = _filterState.value!!.rooms.joinToString(",") -// query = query.where("rooms LIKE '%$rooms%'") -// } -// -// if (_filterState.value!!.levels.isNotEmpty()) { -// val sessionLevels = _filterState.value!!.levels.joinToString("','", "'", "'") -// query.where("sessionLevel IN ($sessionLevels)") -// } -// -// if (_filterState.value!!.sessionTypes.isNotEmpty()) { -// val sessionTypes = _filterState.value!!.sessionTypes.joinToString("','", "'", "'") -// query.where("sessionFormat IN ($sessionTypes)") -// } -// -// if (_filterState.value!!.isBookmarked) { -// val isBookmarked = _filterState.value!!.isBookmarked -// query.where("isBookmarked = '${if (isBookmarked) '1' else '0'}'") -// } -// -// query.orderAsc("startTimestamp") -// -// return query.toSql() } private fun getQuery(): String { @@ -228,7 +234,7 @@ class SessionsViewModel @Inject constructor( fun fetchSessionWithFilter() { viewModelScope.launch { - fetchSessions(query = getQuery()) + fetchFilteredSessions(query = getQuery()) } } @@ -236,14 +242,14 @@ class SessionsViewModel @Inject constructor( _selectedFilterOptions.value = listOf() _filterState.value = SessionsFilterState() viewModelScope.launch { - fetchSessions() + fetchAllSessions() } } fun updateSelectedDay(date: EventDate) { _selectedEventDate.value = date viewModelScope.launch { - fetchSessions(query = getQuery()) + fetchFilteredSessions(query = getQuery()) } } @@ -251,20 +257,28 @@ class SessionsViewModel @Inject constructor( _selectedFilterOptions.value = listOf() _filterState.value = SessionsFilterState() viewModelScope.launch { - fetchSessions(fetchFromRemote = true) + syncDataWorkManager.startSync() } } - suspend fun updateBookmarkStatus( - id: String, - isCurrentlyStarred: Boolean - ): ResourceResult = sessionsRepo.toggleBookmarkStatus(id, isCurrentlyStarred) + suspend fun bookmarkSession(id: String) { + sessionsRepo.bookmarkSession(id = id) + } + + suspend fun unBookmarkSession(id: String) { + sessionsRepo.unBookmarkSession(id = id) + } - fun fetchBookmarkedSessions() { + fun toggleBookmarkFilter() { viewModelScope.launch { _filterState.value = SessionsFilterState() - _filterState.value = _filterState.value?.copy(isBookmarked = true) - fetchSessionWithFilter() + val previousState = _filterState?.value?.isBookmarked ?: false + _filterState.value = _filterState.value?.copy(isBookmarked = !previousState) + if (_filterState?.value?.isBookmarked == true) { + fetchBookmarkSessions() + } else { + fetchAllSessions() + } } } } \ No newline at end of file diff --git a/presentation/src/test/java/com/android254/presentation/sessionDetails/view/SessionDetailsScreenTest.kt b/presentation/src/test/java/com/android254/presentation/sessionDetails/view/SessionDetailsScreenTest.kt index ebf4f9de..a405a4b4 100644 --- a/presentation/src/test/java/com/android254/presentation/sessionDetails/view/SessionDetailsScreenTest.kt +++ b/presentation/src/test/java/com/android254/presentation/sessionDetails/view/SessionDetailsScreenTest.kt @@ -15,16 +15,20 @@ */ package com.android254.presentation.sessionDetails.view +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule -import com.android254.domain.models.ResourceResult +import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle import com.android254.domain.models.Session import com.android254.domain.repos.SessionsRepo +import com.android254.presentation.common.navigation.Screens import com.android254.presentation.common.theme.DroidconKE2023Theme import com.android254.presentation.sessionDetails.SessionDetailsViewModel import com.android254.presentation.sessions.mappers.toSessionDetailsPresentationModal -import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Rule import org.junit.Test @@ -36,21 +40,28 @@ import org.robolectric.shadows.ShadowLog @RunWith(RobolectricTestRunner::class) @Config(instrumentedPackages = ["androidx.loader.content"]) class SessionDetailsScreenTest { + val sessionId = "randomSessionId" + + val mockSavedStateHandle: SavedStateHandle = SavedStateHandle().apply { + set(Screens.SessionDetails.sessionIdNavigationArgument, sessionId) + } @get:Rule val composeTestRule = createComposeRule() + val repo = mockk(relaxed = true) + val viewModel = SessionDetailsViewModel(sessionsRepo = repo, mockSavedStateHandle) + @Before @Throws(Exception::class) fun setUp() { ShadowLog.stream = System.out - val sessionId = "randomSessionId" - val repo = mockk() - val viewModel = SessionDetailsViewModel(sessionsRepo = repo) - - coEvery { repo.fetchSessionById(any()) } returns ResourceResult.Success(data = mockSession) + every { repo.fetchSessionById(sessionId) } returns flowOf(mockSession) + } + @Test + fun `should show top bar and floating action button`() { composeTestRule.setContent { DroidconKE2023Theme() { SessionDetailsScreen( @@ -60,10 +71,6 @@ class SessionDetailsScreenTest { ) } } - } - - @Test - fun `should show top bar and floating action button`() { composeTestRule.onNodeWithTag(TestTag.TOP_BAR).assertExists() composeTestRule.onNodeWithTag(TestTag.TOP_BAR).assertIsDisplayed() @@ -73,7 +80,18 @@ class SessionDetailsScreenTest { @Test fun `should show favourite icon and session banner image`() { - composeTestRule.onNodeWithTag(TestTag.FAVOURITE_ICON).apply { + composeTestRule.setContent { + DroidconKE2023Theme() { + Body( + paddingValues = PaddingValues(1.dp), + darkTheme = false, + sessionDetails = sessionPresentationModel, + bookmarkSession = { }, + unBookmarkSession = { } + ) + } + } + composeTestRule.onNodeWithTag(TestTag.FAVOURITE_ICON, useUnmergedTree = true).apply { assertExists() assertIsDisplayed() } @@ -83,6 +101,17 @@ class SessionDetailsScreenTest { @Test fun `test if speaker-name, session title & description, time, room, level and twitter handle are correctly shown`() { + composeTestRule.setContent { + DroidconKE2023Theme() { + Body( + paddingValues = PaddingValues(10.dp), + darkTheme = false, + sessionDetails = sessionPresentationModel, + bookmarkSession = { }, + unBookmarkSession = { } + ) + } + } composeTestRule.onNodeWithTag(testTag = TestTag.SPEAKER_NAME).assertTextEquals( sessionPresentationModel.speakerName ) @@ -105,6 +134,17 @@ class SessionDetailsScreenTest { @Test fun `test if twitter handle is shown`() { + composeTestRule.setContent { + DroidconKE2023Theme() { + Body( + paddingValues = PaddingValues(10.dp), + darkTheme = false, + sessionDetails = sessionPresentationModel, + bookmarkSession = { }, + unBookmarkSession = { } + ) + } + } composeTestRule.onNodeWithTag(TestTag.TWITTER_HANDLE_TEXT, true).apply { assertExists() } diff --git a/presentation/src/test/java/com/android254/presentation/sessions/view/FakeSyncWorkManager.kt b/presentation/src/test/java/com/android254/presentation/sessions/view/FakeSyncWorkManager.kt new file mode 100644 index 00000000..f75a30df --- /dev/null +++ b/presentation/src/test/java/com/android254/presentation/sessions/view/FakeSyncWorkManager.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2022 DroidconKE + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android254.presentation.sessions.view + +import com.android254.domain.work.SyncDataWorkManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class FakeSyncWorkManager : SyncDataWorkManager { + override val isSyncing: Flow + get() = flow { emit(true) } + override suspend fun startSync() { + } +} \ No newline at end of file diff --git a/presentation/src/test/java/com/android254/presentation/sessions/view/SessionScreenTest.kt b/presentation/src/test/java/com/android254/presentation/sessions/view/SessionScreenTest.kt index 8adfd9e1..ad6801d1 100644 --- a/presentation/src/test/java/com/android254/presentation/sessions/view/SessionScreenTest.kt +++ b/presentation/src/test/java/com/android254/presentation/sessions/view/SessionScreenTest.kt @@ -21,11 +21,15 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.navigation.testing.TestNavHostController import androidx.test.core.app.ApplicationProvider -import com.android254.domain.models.ResourceResult +import com.android254.domain.models.Session import com.android254.domain.repos.SessionsRepo import com.android254.presentation.common.theme.DroidconKE2023Theme -import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test @@ -42,6 +46,7 @@ import org.robolectric.shadows.ShadowLog @Config(instrumentedPackages = ["androidx.loader.content"]) class SessionScreenTest { private val repo = mockk() + private val mockSyncDataWorkManager = FakeSyncWorkManager() @get:Rule val composeTestRule = createComposeRule() @@ -53,17 +58,22 @@ class SessionScreenTest { } @Test - fun hasExpectedButtons() { + fun hasExpectedButtons() = runTest { val navController = TestNavHostController( ApplicationProvider.getApplicationContext() ) - - coEvery { repo.fetchAndSaveSessions() } returns ResourceResult.Empty("") + val viewModel = SessionsViewModel(repo, mockSyncDataWorkManager) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.isRefreshing.collect { + } + } + every { repo.fetchSessions() } returns flowOf(emptyList()) + every { repo.fetchBookmarkedSessions() } returns flowOf(emptyList()) composeTestRule.setContent { DroidconKE2023Theme() { SessionsScreen( - sessionsViewModel = SessionsViewModel(repo) + sessionsViewModel = viewModel ) } } @@ -76,17 +86,18 @@ class SessionScreenTest { } @Test - fun `should show topBar`() { + fun `should show topBar`() = runTest { val navController = TestNavHostController( ApplicationProvider.getApplicationContext() ) - coEvery { repo.fetchAndSaveSessions() } returns ResourceResult.Success(emptyList()) + every { repo.fetchSessions() } returns flowOf(mockSessions) + every { repo.fetchBookmarkedSessions() } returns flowOf(mockSessions) composeTestRule.setContent { DroidconKE2023Theme() { SessionsScreen( - sessionsViewModel = SessionsViewModel(repo) + sessionsViewModel = SessionsViewModel(repo, mockSyncDataWorkManager) ) } } @@ -94,4 +105,64 @@ class SessionScreenTest { composeTestRule.onNodeWithTag("droidcon_topBar_with_Filter").assertExists() composeTestRule.onNodeWithTag("droidcon_topBar_with_Filter").assertIsDisplayed() } -} \ No newline at end of file +} + +val mockSessions = listOf( + Session( + id = "1", + endDateTime = "2023-08-17T12:00:00Z", + endTime = "12:00 PM", + isBookmarked = false, + isKeynote = true, + isServiceSession = false, + sessionImage = "https://example.com/session-1.jpg", + startDateTime = "2023-08-17T10:00:00Z", + startTime = "10:00 AM", + rooms = "Room 1", + speakers = "John Doe, Jane Doe", + remote_id = "1234567890", + description = "This is a keynote session about the future of technology.", + sessionFormat = "Keynote", + sessionLevel = "Beginner", + slug = "keynote-session", + title = "The Future of Technology" + ), + Session( + id = "2", + endDateTime = "2023-08-17T13:00:00Z", + endTime = "1:00 PM", + isBookmarked = true, + isKeynote = false, + isServiceSession = false, + sessionImage = "https://example.com/session-2.jpg", + startDateTime = "2023-08-17T11:00:00Z", + startTime = "11:00 AM", + rooms = "Room 2", + speakers = "Steve Smith, Bill Jones", + remote_id = "9876543210", + description = "This is a session about the latest trends in artificial intelligence.", + sessionFormat = "Workshop", + sessionLevel = "Intermediate", + slug = "ai-trends", + title = "The Latest Trends in Artificial Intelligence" + ), + Session( + id = "3", + endDateTime = "2023-08-17T14:00:00Z", + endTime = "2:00 PM", + isBookmarked = false, + isKeynote = false, + isServiceSession = true, + sessionImage = null, + startDateTime = "2023-08-17T12:00:00Z", + startTime = "12:00 PM", + rooms = "Room 3", + speakers = "No speakers", + remote_id = "", + description = "This is a service session about how to use the conference app.", + sessionFormat = "Service Session", + sessionLevel = "All Levels", + slug = "conference-app", + title = "How to Use the Conference App" + ) +) \ No newline at end of file