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

-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.

-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.

-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
}