From d040fef272cc7192009814ba35c7fa081be23973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Burak=20Akg=C3=BCn?= Date: Mon, 14 Oct 2024 21:11:58 +0200 Subject: [PATCH 1/6] Impl offline mode --- .github/workflows/main.yml | 9 ----- README-de.md | 2 +- README-tr.md | 2 +- README.md | 2 +- .../composeResources/values/strings.xml | 1 + .../data/repository/MjImagesRepository.kt | 15 +++++++-- .../kotlin/data/source/MjImagesDataSource.kt | 4 +++ .../source/local/MjImagesLocalDataSource.kt | 33 +++++++++++++++++++ .../kotlin/domain/model/MjImages.kt | 2 +- .../kotlin/domain/usecase/MjImagesUseCase.kt | 4 +++ .../src/commonMain/kotlin/ui/MjImagesApp.kt | 13 ++++++-- .../commonMain/kotlin/ui/MjImagesViewModel.kt | 27 ++++++++++++--- 12 files changed, 91 insertions(+), 23 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d05a8ed..63adf6f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -156,15 +156,6 @@ jobs: name: apk path: androidApp/build/outputs/apk/debug/androidApp-debug.apk - - name: Upload Maestro Test Results - if: always() - uses: actions/upload-artifact@v4 - with: - name: maestro-test-results - path: | - ${{ github.workspace }}/report*.xml - ~/.maestro/tests/**/* - desktop-build: runs-on: ubuntu-latest timeout-minutes: 45 diff --git a/README-de.md b/README-de.md index 4d41937..2c160aa 100644 --- a/README-de.md +++ b/README-de.md @@ -8,7 +8,7 @@ Diese Anwendung wurde entwickelt, um die Bilder von MidJourney anzuzeigen. Die Anwendung wurde mit Compose Multiplatform entwickelt. Die Anwendung läuft auf den Plattformen Android, iOS, Web, Wear OS, Android Automotive, Android TV

compose-header

-Die Anwendung wurde im MVVM-Konzept mit Kotlin und Jetpack Compose entwickelt. Es wurden Netzwerkanforderungszustände, Endlos-Pagination, Bildladeprozesse und Bildcaching durchgeführt. +Die Anwendung wurde im MVVM-Konzept mit Kotlin und Jetpack Compose entwickelt. Es wurden Netzwerkanforderungszustände, Endlos-Pagination, Bildladeprozesse, Offline-Modus und Bildcaching durchgeführt. ## Verwendete Bibliotheken diff --git a/README-tr.md b/README-tr.md index 0d0204f..4e307dd 100644 --- a/README-tr.md +++ b/README-tr.md @@ -9,7 +9,7 @@ Bu uygulama, çoklu platform desteği ile MidJourney'ın oluşturduğu resimleri Compose Multiplatform ile geliştirilmiştir. Uygulama, Android, iOS, Web, Wear OS, Android Automotive, Android TV platformlarında çalışmaktadır.

compose-header

-Kotlin ve Jetpack Compose kullanılarak MVVM konseptinde geliştirtirildi. Network request state'leri, endless pagination, image loading ve image caching işlemleri yapılmıştır. +Kotlin ve Jetpack Compose kullanılarak MVVM konseptinde geliştirtirildi. Network request state'leri, endless pagination, image loading, çevrimdışı modu ve image caching işlemleri yapılmıştır. ## Kullanılan Kütüphaneler diff --git a/README.md b/README.md index 846afae..361f0c4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This application is developed to display the images created by MidJourney. The application is developed with Compose Multiplatform and works on Android, iOS, Web, Wear OS, Android Automotive, Android TV platforms.

compose-header

-Application developed in the MVVM concept using Kotlin and Jetpack Compose. Network request states, endless pagination, image loading, and image caching processes were performed. +Application developed in the MVVM concept using Kotlin and Jetpack Compose. Network request states, endless pagination, image loading, offline mode and image caching processes were performed. ## Libraries Used diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index e562bc5..1886075 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -1,3 +1,4 @@ 1) Click image to open in browser\n2) Long click to preview image + Failed to fetch images, using offline mode \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/data/repository/MjImagesRepository.kt b/shared/src/commonMain/kotlin/data/repository/MjImagesRepository.kt index f392998..6bcef0c 100644 --- a/shared/src/commonMain/kotlin/data/repository/MjImagesRepository.kt +++ b/shared/src/commonMain/kotlin/data/repository/MjImagesRepository.kt @@ -15,9 +15,14 @@ class MjImagesRepository : KoinComponent { fun getImages( page: Int, ): Flow = flow { - emit( - remoteSource.getImages(page) - ) + if (localSource.isCacheValid()) { + val imagesCache = localSource.getImages(page) + imagesCache?.let { emit(it) } + } + + val mjImagesResponse = remoteSource.getImages(page) + localSource.saveImages(page,mjImagesResponse) + emit(mjImagesResponse) } suspend fun isEligibleToShowSnackMessage(): Boolean = @@ -33,4 +38,8 @@ class MjImagesRepository : KoinComponent { suspend fun setDarkMode(enabled: Boolean) { localSource.setDarkMode(enabled) } + + suspend fun clearImages() { + localSource.clearImages() + } } diff --git a/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt b/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt index 63b6cc8..8b000cb 100644 --- a/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt +++ b/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt @@ -10,6 +10,10 @@ interface MjImagesDataSource { suspend fun setSnackMessageShown() suspend fun isDarkModeEnabled(): Boolean suspend fun setDarkMode(enabled: Boolean) + suspend fun isCacheValid(): Boolean + suspend fun getImages(page: Int): MjImagesResponse? + suspend fun clearImages() + suspend fun saveImages(page: Int, response: MjImagesResponse) } interface Remote { diff --git a/shared/src/commonMain/kotlin/data/source/local/MjImagesLocalDataSource.kt b/shared/src/commonMain/kotlin/data/source/local/MjImagesLocalDataSource.kt index c30acda..faa76ae 100644 --- a/shared/src/commonMain/kotlin/data/source/local/MjImagesLocalDataSource.kt +++ b/shared/src/commonMain/kotlin/data/source/local/MjImagesLocalDataSource.kt @@ -3,7 +3,10 @@ package data.source.local import com.russhwolf.settings.Settings import com.russhwolf.settings.set import data.source.MjImagesDataSource +import data.source.remote.model.MjImagesResponse import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import util.DispatcherProvider class MjImagesLocalDataSource( @@ -32,8 +35,38 @@ class MjImagesLocalDataSource( } } + override suspend fun isCacheValid(): Boolean = + withContext(dispatcherProvider.io) { + settings.hasKey(CACHE_PREFIX_KEY + 1) + } + + override suspend fun getImages(page: Int): MjImagesResponse? = + withContext(dispatcherProvider.io) { + val jsonString = settings.getStringOrNull(CACHE_PREFIX_KEY + page) ?: return@withContext null + Json.decodeFromString(jsonString) + } + + override suspend fun clearImages() { + withContext(dispatcherProvider.io) { + settings.keys.filter { + it.startsWith(CACHE_PREFIX_KEY) + }.forEach { + settings.remove(it) + } + } + } + + override suspend fun saveImages(page: Int, response: MjImagesResponse) { + withContext(dispatcherProvider.io) { + if (response.mjImageResponses.isNullOrEmpty()) return@withContext + if (page == 1) clearImages() + settings[CACHE_PREFIX_KEY + page] = Json.encodeToString(response) + } + } + companion object { private const val SNACK_MESSAGE_KEY = "SNACK_MESSAGE_KEY" private const val DARK_MODE_KEY = "DARK_MODE_KEY" + private const val CACHE_PREFIX_KEY = "CACHE_PAGE_" } } diff --git a/shared/src/commonMain/kotlin/domain/model/MjImages.kt b/shared/src/commonMain/kotlin/domain/model/MjImages.kt index 357cbe7..99e75f9 100644 --- a/shared/src/commonMain/kotlin/domain/model/MjImages.kt +++ b/shared/src/commonMain/kotlin/domain/model/MjImages.kt @@ -14,7 +14,7 @@ data class MjImages( operator fun plus(images: MjImages): MjImages = MjImages( currentPage = images.currentPage, - images = this.images + images.images, + images = (this.images + images.images).distinct(), totalPages = images.totalPages ) } diff --git a/shared/src/commonMain/kotlin/domain/usecase/MjImagesUseCase.kt b/shared/src/commonMain/kotlin/domain/usecase/MjImagesUseCase.kt index 5ee1347..58cedeb 100644 --- a/shared/src/commonMain/kotlin/domain/usecase/MjImagesUseCase.kt +++ b/shared/src/commonMain/kotlin/domain/usecase/MjImagesUseCase.kt @@ -21,4 +21,8 @@ class MjImagesUseCase : KoinComponent { suspend fun setDarkMode(enabled: Boolean) { repository.setDarkMode(enabled) } + + suspend fun clearImages() { + repository.clearImages() + } } diff --git a/shared/src/commonMain/kotlin/ui/MjImagesApp.kt b/shared/src/commonMain/kotlin/ui/MjImagesApp.kt index 772a629..33e8d64 100644 --- a/shared/src/commonMain/kotlin/ui/MjImagesApp.kt +++ b/shared/src/commonMain/kotlin/ui/MjImagesApp.kt @@ -75,8 +75,8 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImagePainter.State.Success import coil3.compose.rememberAsyncImagePainter import coil3.compose.setSingletonImageLoaderFactory @@ -93,7 +93,7 @@ import util.OnBottomReached import util.getAsyncImageLoader import util.getImageProvider -@OptIn(ExperimentalMaterialApi::class, ExperimentalCoilApi::class) +@OptIn(ExperimentalMaterialApi::class) @Composable fun MjImagesApp( viewModel: MjImagesViewModel @@ -118,6 +118,15 @@ fun MjImagesApp( } } + LaunchedEffect( + viewModel.snackMessage, + LocalLifecycleOwner.current + ) { + viewModel.snackMessage.collect { + scaffoldState.snackbarHostState.showSnackbar(it) + } + } + LaunchedEffect(Unit) { if (viewModel.isEligibleToShowSnackBar()) { scaffoldState.snackbarHostState.showSnackbar(getString(Res.string.snack_message)) diff --git a/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt b/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt index 74190db..1a71364 100644 --- a/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt +++ b/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt @@ -6,14 +6,20 @@ import domain.model.MjImages import domain.model.State import domain.usecase.MjImagesFetchUseCase import domain.usecase.MjImagesUseCase +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import midjourneyimagescomposemultiplatform.shared.generated.resources.Res +import midjourneyimagescomposemultiplatform.shared.generated.resources.failed_fetch_message +import org.jetbrains.compose.resources.getString class MjImagesViewModel( private val fetchUseCase: MjImagesFetchUseCase, @@ -32,6 +38,9 @@ class MjImagesViewModel( private val _dialogPreviewUrl: MutableStateFlow = MutableStateFlow("") val dialogPreviewUrl: StateFlow = _dialogPreviewUrl.asStateFlow() + private val _snackMessage: MutableSharedFlow = MutableSharedFlow() + val snackMessage: SharedFlow = _snackMessage.asSharedFlow() + init { checkTheme() fetchImages() @@ -46,8 +55,11 @@ class MjImagesViewModel( } fun refreshImages() { - _images.value = MjImages() - fetchImages() + viewModelScope.launch { + useCase.clearImages() + _images.value = MjImages() + fetchImages() + } } private fun fetchImages( @@ -57,20 +69,25 @@ class MjImagesViewModel( .getImages(page) .onStart { _state.value = State.LOADING } .onEach { images -> - if (images.isEmpty()) { + if (_images.value.images.isEmpty() && images.isEmpty()) { _state.value = State.EMPTY } else { _state.value = State.CONTENT _images.value += images } }.catch { - _state.value = State.ERROR + it.printStackTrace() + if (_images.value.isEmpty()) { + _state.value = State.ERROR + } else { + _snackMessage.emit(getString(Res.string.failed_fetch_message)) + } } .launchIn(viewModelScope) } suspend fun isEligibleToShowSnackBar(): Boolean = - useCase.isEligibleToShowSnackMessage() + useCase.isEligibleToShowSnackMessage() && _images.value.images.isNotEmpty() suspend fun setSnackMessageShown() { useCase.setSnackMessageShown() From ccda96df77cdb61d9478133483a60702ebb75527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Burak=20Akg=C3=BCn?= Date: Mon, 14 Oct 2024 21:17:06 +0200 Subject: [PATCH 2/6] Fix link / detekt issues --- gradle/build-logic/convention/src/main/kotlin/Compose.kt | 4 +++- shared/detekt-baseline.xml | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/gradle/build-logic/convention/src/main/kotlin/Compose.kt b/gradle/build-logic/convention/src/main/kotlin/Compose.kt index 75207a0..e691ff8 100644 --- a/gradle/build-logic/convention/src/main/kotlin/Compose.kt +++ b/gradle/build-logic/convention/src/main/kotlin/Compose.kt @@ -1,13 +1,15 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag fun Project.composeCompiler(block: ComposeCompilerGradlePluginExtension.() -> Unit) { extensions.configure(block) } + fun Project.configureCompose() { composeCompiler { - enableStrongSkippingMode.set(true) + featureFlags.add(ComposeFeatureFlag.StrongSkipping) includeSourceInformation.set(true) diff --git a/shared/detekt-baseline.xml b/shared/detekt-baseline.xml index 2f821dc..430ff61 100644 --- a/shared/detekt-baseline.xml +++ b/shared/detekt-baseline.xml @@ -11,11 +11,13 @@ FunctionNaming:MjImagesApp.kt$@Composable fun PreviewDialog( hqImageUrl: String, onDismissed: () -> Unit, ) FunctionNaming:MjImagesApp.kt$@Composable fun PreviewImage(hqImageUrl: String) FunctionNaming:MjImagesApp.kt$@Composable fun ScrollToTopButton( onClick: () -> Unit, modifier: Modifier = Modifier ) - FunctionNaming:MjImagesApp.kt$@OptIn(ExperimentalMaterialApi::class, ExperimentalCoilApi::class) @Composable fun MjImagesApp( viewModel: MjImagesViewModel ) + FunctionNaming:MjImagesApp.kt$@OptIn(ExperimentalMaterialApi::class) @Composable fun MjImagesApp( viewModel: MjImagesViewModel ) + FunctionNaming:String0.commonMain.kt$@InternalResourceApi internal fun _collectCommonMainString0Resources(map: MutableMap<String, StringResource>) + FunctionNaming:String0.commonMain.kt$private fun init_failed_fetch_message(): StringResource FunctionNaming:String0.commonMain.kt$private fun init_snack_message(): StringResource FunctionNaming:Theme.kt$@Composable fun AppTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) FunctionNaming:main.ios.kt$fun MainViewController(viewModel: MjImagesViewModel): UIViewController - LongMethod:MjImagesApp.kt$@OptIn(ExperimentalMaterialApi::class, ExperimentalCoilApi::class) @Composable fun MjImagesApp( viewModel: MjImagesViewModel ) + LongMethod:MjImagesApp.kt$@OptIn(ExperimentalMaterialApi::class) @Composable fun MjImagesApp( viewModel: MjImagesViewModel ) MagicNumber:Colors.kt$0xFF1F1B16 MagicNumber:Colors.kt$0xFF3E2D16 MagicNumber:Colors.kt$0xFF452B00 @@ -37,6 +39,8 @@ MagicNumber:MjImagesApp.kt$24f MagicNumber:String0.commonMain.kt$10 MagicNumber:String0.commonMain.kt$109 + MagicNumber:String0.commonMain.kt$84 + MagicNumber:String0.commonMain.kt$95 UnusedPrivateProperty:build.gradle.kts$val androidInstrumentedTest by getting { dependencies { implementation(libs.androidxUiTestJunit4) implementation(libs.androidxUiTestManifest) } } UnusedPrivateProperty:build.gradle.kts$val jsMain by getting { dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(project(":shared")) } } UnusedPrivateProperty:build.gradle.kts$val jvmMain by getting { dependencies { implementation(project(":shared")) implementation(compose.desktop.currentOs) } } From 4609a6d5208bf1e9409e44a01fbedd59217744f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Burak=20Akg=C3=BCn?= Date: Mon, 14 Oct 2024 21:42:19 +0200 Subject: [PATCH 3/6] Impl snack bar offline message and offline UI test --- .../kotlin/ui/MjImagesScreenTest.kt | 23 +++---- .../kotlin/ui/ScreenTestUtil.kt | 63 ++++++++++++++++--- .../source/remote/model/MjImagesResponse.kt | 10 +-- .../src/commonMain/kotlin/ui/MjImagesApp.kt | 28 +++++---- .../commonMain/kotlin/ui/MjImagesViewModel.kt | 2 +- 5 files changed, 86 insertions(+), 40 deletions(-) diff --git a/shared/src/androidInstrumentedTest/kotlin/ui/MjImagesScreenTest.kt b/shared/src/androidInstrumentedTest/kotlin/ui/MjImagesScreenTest.kt index fa83c2b..126171f 100644 --- a/shared/src/androidInstrumentedTest/kotlin/ui/MjImagesScreenTest.kt +++ b/shared/src/androidInstrumentedTest/kotlin/ui/MjImagesScreenTest.kt @@ -30,8 +30,8 @@ class MjImagesScreenTest { composeTestRule.setContent { MjImagesApp(initAppAndMockViewModel( - LocalContext.current, - EmptyMjImagesDataSource() + context = LocalContext.current, + remoteDataSource = EmptyMjImagesDataSource(), ).also { viewModel = it }) } @@ -46,23 +46,24 @@ class MjImagesScreenTest { } @Test - fun testErrorScreenUi() { + fun testOfflineScreenUi() { var viewModel: MjImagesViewModel? = null composeTestRule.setContent { MjImagesApp(initAppAndMockViewModel( - LocalContext.current, - ErrorMjImagesDataSource() + context = LocalContext.current, + remoteDataSource = ErrorMjImagesDataSource(), + localDataSource = OfflineMjImagesLocalDataSource(), ).also { viewModel = it }) } composeTestRule .waitUntil(3000) { - viewModel?.state?.value == State.ERROR + viewModel?.state?.value == State.CONTENT } composeTestRule - .onNodeWithText("Error") + .onNodeWithText("offline", substring = true) .assertIsDisplayed() } @@ -72,8 +73,8 @@ class MjImagesScreenTest { composeTestRule.setContent { MjImagesApp(initAppAndMockViewModel( - LocalContext.current, - SuccessMjImagesDataSource() + context = LocalContext.current, + remoteDataSource = SuccessMjImagesDataSource(), ).also { viewModel = it }) } @@ -114,8 +115,8 @@ class MjImagesScreenTest { composeTestRule.setContent { MjImagesApp(initAppAndMockViewModel( - LocalContext.current, - SuccessMjImagesDataSource() + context = LocalContext.current, + remoteDataSource = SuccessMjImagesDataSource(), ).also { viewModel = it }) snackMessage = stringResource(Res.string.snack_message) } diff --git a/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt b/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt index 364c30d..5258c9d 100644 --- a/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt +++ b/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt @@ -14,10 +14,12 @@ import java.io.IOException // setAppContext for ImageLoader &-init koin - mock response - return viewModel fun initAppAndMockViewModel( context: Context, - dataSource: MjImagesDataSource.Remote? = null + remoteDataSource: MjImagesDataSource.Remote? = null, + localDataSource: MjImagesDataSource.Local? = null ): MjImagesViewModel = initKoin { androidContext(androidContext = context) - if (dataSource != null) modules(module { factory { dataSource } }) + if (remoteDataSource != null) modules(module { factory { remoteDataSource } }) + if (localDataSource != null) modules(module { factory { localDataSource } }) loadKoinModules(module { viewModelOf(::MjImagesViewModel) }) }.koin.get() @@ -46,14 +48,7 @@ class EmptyMjImagesDataSource : MjImagesDataSource.Remote { override suspend fun getImages( page: Int - ): MjImagesResponse = - MjImagesResponse( - currentPage = 0, - totalPages = 0, - mjImageResponses = null, - pageSize = null, - totalImages = null, - ) + ): MjImagesResponse = MjImagesResponse() } class ErrorMjImagesDataSource : MjImagesDataSource.Remote { @@ -63,3 +58,51 @@ class ErrorMjImagesDataSource : MjImagesDataSource.Remote { ): MjImagesResponse = throw IOException("Unknown") } + +class OfflineMjImagesLocalDataSource : MjImagesDataSource.Local { + + override suspend fun isEligibleToShowSnackMessage(): Boolean { + return false + } + + override suspend fun setSnackMessageShown() { + // no-op + } + + override suspend fun isDarkModeEnabled(): Boolean { + return false + } + + override suspend fun setDarkMode(enabled: Boolean) { + // no-op + } + + override suspend fun isCacheValid(): Boolean { + return true + } + + override suspend fun getImages(page: Int): MjImagesResponse? { + return MjImagesResponse( + currentPage = 1, + totalPages = 1, + mjImageResponses = listOf( + MjImageResponse( + date = "", + imageUrl = "", + ratio = 1.0, + hqImageUrl = "" + ) + ), + pageSize = null, + totalImages = null, + ) + } + + override suspend fun clearImages() { + // no-op + } + + override suspend fun saveImages(page: Int, response: MjImagesResponse) { + // no-op + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/data/source/remote/model/MjImagesResponse.kt b/shared/src/commonMain/kotlin/data/source/remote/model/MjImagesResponse.kt index 4af1387..b174c6f 100644 --- a/shared/src/commonMain/kotlin/data/source/remote/model/MjImagesResponse.kt +++ b/shared/src/commonMain/kotlin/data/source/remote/model/MjImagesResponse.kt @@ -6,13 +6,13 @@ import kotlinx.serialization.Serializable @Serializable data class MjImagesResponse( @SerialName("currentPage") - val currentPage: Int?, + val currentPage: Int? = null, @SerialName("images") - val mjImageResponses: List?, + val mjImageResponses: List? = null, @SerialName("pageSize") - val pageSize: Int?, + val pageSize: Int? = null, @SerialName("totalImages") - val totalImages: Int?, + val totalImages: Int? = null, @SerialName("totalPages") - val totalPages: Int? + val totalPages: Int? = null ) diff --git a/shared/src/commonMain/kotlin/ui/MjImagesApp.kt b/shared/src/commonMain/kotlin/ui/MjImagesApp.kt index 33e8d64..bbe66ab 100644 --- a/shared/src/commonMain/kotlin/ui/MjImagesApp.kt +++ b/shared/src/commonMain/kotlin/ui/MjImagesApp.kt @@ -127,13 +127,6 @@ fun MjImagesApp( } } - LaunchedEffect(Unit) { - if (viewModel.isEligibleToShowSnackBar()) { - scaffoldState.snackbarHostState.showSnackbar(getString(Res.string.snack_message)) - viewModel.setSnackMessageShown() - } - } - Scaffold(scaffoldState = scaffoldState) { val isRefreshing = remember { derivedStateOf { @@ -150,12 +143,21 @@ fun MjImagesApp( when (state) { State.ERROR -> ErrorScreen(onRefresh) State.EMPTY -> EmptyScreen(onRefresh) - else -> MjImagesList( - onLoadMore = viewModel::loadMore, - images = images, - state = listState, - ) { hqImageUrl -> - viewModel.showPreviewDialog(hqImageUrl) + else -> { + LaunchedEffect(Unit) { + if (viewModel.isEligibleToShowSnackBar()) { + scaffoldState.snackbarHostState.showSnackbar(getString(Res.string.snack_message)) + viewModel.setSnackMessageShown() + } + } + + MjImagesList( + onLoadMore = viewModel::loadMore, + images = images, + state = listState, + ) { hqImageUrl -> + viewModel.showPreviewDialog(hqImageUrl) + } } } PullRefreshIndicator( diff --git a/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt b/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt index 1a71364..a5ced21 100644 --- a/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt +++ b/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt @@ -87,7 +87,7 @@ class MjImagesViewModel( } suspend fun isEligibleToShowSnackBar(): Boolean = - useCase.isEligibleToShowSnackMessage() && _images.value.images.isNotEmpty() + useCase.isEligibleToShowSnackMessage() suspend fun setSnackMessageShown() { useCase.setSnackMessageShown() From ee26ec6dd7d89668f13fcd7a14863577230eb0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Burak=20Akg=C3=BCn?= Date: Mon, 14 Oct 2024 22:15:35 +0200 Subject: [PATCH 4/6] Fix failed unit tests --- .../kotlin/ui/ScreenTestUtil.kt | 33 +++++-------------- .../fakes/MjImagesLocalFakeDataSource.kt | 11 +++++++ 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt b/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt index 5258c9d..e5475d7 100644 --- a/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt +++ b/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt @@ -61,28 +61,18 @@ class ErrorMjImagesDataSource : MjImagesDataSource.Remote { class OfflineMjImagesLocalDataSource : MjImagesDataSource.Local { - override suspend fun isEligibleToShowSnackMessage(): Boolean { - return false - } + override suspend fun isEligibleToShowSnackMessage(): Boolean = false - override suspend fun setSnackMessageShown() { - // no-op - } + override suspend fun setSnackMessageShown() = Unit - override suspend fun isDarkModeEnabled(): Boolean { - return false - } + override suspend fun isDarkModeEnabled(): Boolean = false - override suspend fun setDarkMode(enabled: Boolean) { - // no-op - } + override suspend fun setDarkMode(enabled: Boolean) = Unit - override suspend fun isCacheValid(): Boolean { - return true - } + override suspend fun isCacheValid(): Boolean = true - override suspend fun getImages(page: Int): MjImagesResponse? { - return MjImagesResponse( + override suspend fun getImages(page: Int): MjImagesResponse = + MjImagesResponse( currentPage = 1, totalPages = 1, mjImageResponses = listOf( @@ -96,13 +86,8 @@ class OfflineMjImagesLocalDataSource : MjImagesDataSource.Local { pageSize = null, totalImages = null, ) - } - override suspend fun clearImages() { - // no-op - } + override suspend fun clearImages() = Unit - override suspend fun saveImages(page: Int, response: MjImagesResponse) { - // no-op - } + override suspend fun saveImages(page: Int, response: MjImagesResponse) = Unit } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt b/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt index de6f51a..ee75eac 100644 --- a/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt +++ b/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt @@ -1,6 +1,7 @@ package fakes import data.source.MjImagesDataSource +import data.source.remote.model.MjImagesResponse class MjImagesLocalFakeDataSource : MjImagesDataSource.Local { @@ -12,4 +13,14 @@ class MjImagesLocalFakeDataSource : MjImagesDataSource.Local { override suspend fun isDarkModeEnabled(): Boolean = false override suspend fun setDarkMode(enabled: Boolean) = Unit + + override suspend fun isCacheValid(): Boolean = false + + override suspend fun getImages(page: Int): MjImagesResponse? = null + + override suspend fun clearImages() = Unit + + override suspend fun saveImages( + page: Int, response: MjImagesResponse + ) = Unit } From 8e8fcd3111ac6e3af37631040d9af8c3c94c3790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Burak=20Akg=C3=BCn?= Date: Mon, 14 Oct 2024 22:21:26 +0200 Subject: [PATCH 5/6] Fix detekt --- shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt b/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt index e5475d7..9ce8cc0 100644 --- a/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt +++ b/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt @@ -90,4 +90,4 @@ class OfflineMjImagesLocalDataSource : MjImagesDataSource.Local { override suspend fun clearImages() = Unit override suspend fun saveImages(page: Int, response: MjImagesResponse) = Unit -} \ No newline at end of file +} From 2fe088498312b10e88079b92798ac80719e561d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Burak=20Akg=C3=BCn?= Date: Mon, 14 Oct 2024 22:39:22 +0200 Subject: [PATCH 6/6] Remove clear cache mechanism on refresh and rename saveImages function --- .../androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt | 2 +- .../kotlin/data/repository/MjImagesRepository.kt | 6 +----- .../commonMain/kotlin/data/source/MjImagesDataSource.kt | 2 +- .../kotlin/data/source/local/MjImagesLocalDataSource.kt | 5 +++-- .../commonMain/kotlin/domain/usecase/MjImagesUseCase.kt | 4 ---- shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt | 7 ++----- .../kotlin/fakes/MjImagesLocalFakeDataSource.kt | 8 ++++---- 7 files changed, 12 insertions(+), 22 deletions(-) diff --git a/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt b/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt index 9ce8cc0..aefc70f 100644 --- a/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt +++ b/shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt @@ -89,5 +89,5 @@ class OfflineMjImagesLocalDataSource : MjImagesDataSource.Local { override suspend fun clearImages() = Unit - override suspend fun saveImages(page: Int, response: MjImagesResponse) = Unit + override suspend fun cacheResponse(page: Int, response: MjImagesResponse) = Unit } diff --git a/shared/src/commonMain/kotlin/data/repository/MjImagesRepository.kt b/shared/src/commonMain/kotlin/data/repository/MjImagesRepository.kt index 6bcef0c..41b5b5c 100644 --- a/shared/src/commonMain/kotlin/data/repository/MjImagesRepository.kt +++ b/shared/src/commonMain/kotlin/data/repository/MjImagesRepository.kt @@ -21,7 +21,7 @@ class MjImagesRepository : KoinComponent { } val mjImagesResponse = remoteSource.getImages(page) - localSource.saveImages(page,mjImagesResponse) + localSource.cacheResponse(page,mjImagesResponse) emit(mjImagesResponse) } @@ -38,8 +38,4 @@ class MjImagesRepository : KoinComponent { suspend fun setDarkMode(enabled: Boolean) { localSource.setDarkMode(enabled) } - - suspend fun clearImages() { - localSource.clearImages() - } } diff --git a/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt b/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt index 8b000cb..33a688d 100644 --- a/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt +++ b/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt @@ -13,7 +13,7 @@ interface MjImagesDataSource { suspend fun isCacheValid(): Boolean suspend fun getImages(page: Int): MjImagesResponse? suspend fun clearImages() - suspend fun saveImages(page: Int, response: MjImagesResponse) + suspend fun cacheResponse(page: Int, response: MjImagesResponse) } interface Remote { diff --git a/shared/src/commonMain/kotlin/data/source/local/MjImagesLocalDataSource.kt b/shared/src/commonMain/kotlin/data/source/local/MjImagesLocalDataSource.kt index faa76ae..848d58d 100644 --- a/shared/src/commonMain/kotlin/data/source/local/MjImagesLocalDataSource.kt +++ b/shared/src/commonMain/kotlin/data/source/local/MjImagesLocalDataSource.kt @@ -42,7 +42,8 @@ class MjImagesLocalDataSource( override suspend fun getImages(page: Int): MjImagesResponse? = withContext(dispatcherProvider.io) { - val jsonString = settings.getStringOrNull(CACHE_PREFIX_KEY + page) ?: return@withContext null + val jsonString = + settings.getStringOrNull(CACHE_PREFIX_KEY + page) ?: return@withContext null Json.decodeFromString(jsonString) } @@ -56,7 +57,7 @@ class MjImagesLocalDataSource( } } - override suspend fun saveImages(page: Int, response: MjImagesResponse) { + override suspend fun cacheResponse(page: Int, response: MjImagesResponse) { withContext(dispatcherProvider.io) { if (response.mjImageResponses.isNullOrEmpty()) return@withContext if (page == 1) clearImages() diff --git a/shared/src/commonMain/kotlin/domain/usecase/MjImagesUseCase.kt b/shared/src/commonMain/kotlin/domain/usecase/MjImagesUseCase.kt index 58cedeb..5ee1347 100644 --- a/shared/src/commonMain/kotlin/domain/usecase/MjImagesUseCase.kt +++ b/shared/src/commonMain/kotlin/domain/usecase/MjImagesUseCase.kt @@ -21,8 +21,4 @@ class MjImagesUseCase : KoinComponent { suspend fun setDarkMode(enabled: Boolean) { repository.setDarkMode(enabled) } - - suspend fun clearImages() { - repository.clearImages() - } } diff --git a/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt b/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt index a5ced21..bc8dea2 100644 --- a/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt +++ b/shared/src/commonMain/kotlin/ui/MjImagesViewModel.kt @@ -55,11 +55,8 @@ class MjImagesViewModel( } fun refreshImages() { - viewModelScope.launch { - useCase.clearImages() - _images.value = MjImages() - fetchImages() - } + _images.value = MjImages() + fetchImages() } private fun fetchImages( diff --git a/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt b/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt index ee75eac..538357e 100644 --- a/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt +++ b/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt @@ -5,8 +5,7 @@ import data.source.remote.model.MjImagesResponse class MjImagesLocalFakeDataSource : MjImagesDataSource.Local { - override suspend fun isEligibleToShowSnackMessage(): Boolean = - true + override suspend fun isEligibleToShowSnackMessage(): Boolean = true override suspend fun setSnackMessageShown() = Unit @@ -20,7 +19,8 @@ class MjImagesLocalFakeDataSource : MjImagesDataSource.Local { override suspend fun clearImages() = Unit - override suspend fun saveImages( - page: Int, response: MjImagesResponse + override suspend fun cacheResponse( + page: Int, + response: MjImagesResponse ) = Unit }