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/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) } } 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..aefc70f 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,36 @@ class ErrorMjImagesDataSource : MjImagesDataSource.Remote { ): MjImagesResponse = throw IOException("Unknown") } + +class OfflineMjImagesLocalDataSource : MjImagesDataSource.Local { + + override suspend fun isEligibleToShowSnackMessage(): Boolean = false + + override suspend fun setSnackMessageShown() = Unit + + override suspend fun isDarkModeEnabled(): Boolean = false + + override suspend fun setDarkMode(enabled: Boolean) = Unit + + override suspend fun isCacheValid(): Boolean = true + + override suspend fun getImages(page: Int): MjImagesResponse = + MjImagesResponse( + currentPage = 1, + totalPages = 1, + mjImageResponses = listOf( + MjImageResponse( + date = "", + imageUrl = "", + ratio = 1.0, + hqImageUrl = "" + ) + ), + pageSize = null, + totalImages = null, + ) + + override suspend fun clearImages() = Unit + + override suspend fun cacheResponse(page: Int, response: MjImagesResponse) = Unit +} 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..41b5b5c 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.cacheResponse(page,mjImagesResponse) + emit(mjImagesResponse) } suspend fun isEligibleToShowSnackMessage(): Boolean = diff --git a/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt b/shared/src/commonMain/kotlin/data/source/MjImagesDataSource.kt index 63b6cc8..33a688d 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 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 c30acda..848d58d 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,39 @@ 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 cacheResponse(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/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/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/ui/MjImagesApp.kt b/shared/src/commonMain/kotlin/ui/MjImagesApp.kt index 772a629..bbe66ab 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,10 +118,12 @@ fun MjImagesApp( } } - LaunchedEffect(Unit) { - if (viewModel.isEligibleToShowSnackBar()) { - scaffoldState.snackbarHostState.showSnackbar(getString(Res.string.snack_message)) - viewModel.setSnackMessageShown() + LaunchedEffect( + viewModel.snackMessage, + LocalLifecycleOwner.current + ) { + viewModel.snackMessage.collect { + scaffoldState.snackbarHostState.showSnackbar(it) } } @@ -141,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 74190db..bc8dea2 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() @@ -57,14 +66,19 @@ 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) } diff --git a/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt b/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt index de6f51a..538357e 100644 --- a/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt +++ b/shared/src/commonTest/kotlin/fakes/MjImagesLocalFakeDataSource.kt @@ -1,15 +1,26 @@ package fakes import data.source.MjImagesDataSource +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 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 cacheResponse( + page: Int, + response: MjImagesResponse + ) = Unit }