Skip to content

Commit

Permalink
feature/offline-mode (#306)
Browse files Browse the repository at this point in the history
* Impl offline mode
  • Loading branch information
mbakgun authored Oct 14, 2024
1 parent 8b693de commit 769afd4
Show file tree
Hide file tree
Showing 17 changed files with 167 additions and 61 deletions.
9 changes: 0 additions & 9 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README-de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<p align="center"><img src="image-assets/1.gif" alt="compose-header" /><br><br></p>
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

Expand Down
2 changes: 1 addition & 1 deletion README-tr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center"><img src="image-assets/1.gif" alt="compose-header" /><br><br></p>
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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center"><img src="image-assets/1.gif" alt="compose-header" /><br><br></p>
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

Expand Down
4 changes: 3 additions & 1 deletion gradle/build-logic/convention/src/main/kotlin/Compose.kt
Original file line number Diff line number Diff line change
@@ -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<ComposeCompilerGradlePluginExtension>(block)
}

fun Project.configureCompose() {
composeCompiler {
enableStrongSkippingMode.set(true)
featureFlags.add(ComposeFeatureFlag.StrongSkipping)

includeSourceInformation.set(true)

Expand Down
8 changes: 6 additions & 2 deletions shared/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
<ID>FunctionNaming:MjImagesApp.kt$@Composable fun PreviewDialog( hqImageUrl: String, onDismissed: () -&gt; Unit, )</ID>
<ID>FunctionNaming:MjImagesApp.kt$@Composable fun PreviewImage(hqImageUrl: String)</ID>
<ID>FunctionNaming:MjImagesApp.kt$@Composable fun ScrollToTopButton( onClick: () -&gt; Unit, modifier: Modifier = Modifier )</ID>
<ID>FunctionNaming:MjImagesApp.kt$@OptIn(ExperimentalMaterialApi::class, ExperimentalCoilApi::class) @Composable fun MjImagesApp( viewModel: MjImagesViewModel )</ID>
<ID>FunctionNaming:MjImagesApp.kt$@OptIn(ExperimentalMaterialApi::class) @Composable fun MjImagesApp( viewModel: MjImagesViewModel )</ID>
<ID>FunctionNaming:String0.commonMain.kt$@InternalResourceApi internal fun _collectCommonMainString0Resources(map: MutableMap&lt;String, StringResource&gt;)</ID>
<ID>FunctionNaming:String0.commonMain.kt$private fun init_failed_fetch_message(): StringResource</ID>
<ID>FunctionNaming:String0.commonMain.kt$private fun init_snack_message(): StringResource</ID>
<ID>FunctionNaming:Theme.kt$@Composable fun AppTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -&gt; Unit )</ID>
<ID>FunctionNaming:main.ios.kt$fun MainViewController(viewModel: MjImagesViewModel): UIViewController</ID>
<ID>LongMethod:MjImagesApp.kt$@OptIn(ExperimentalMaterialApi::class, ExperimentalCoilApi::class) @Composable fun MjImagesApp( viewModel: MjImagesViewModel )</ID>
<ID>LongMethod:MjImagesApp.kt$@OptIn(ExperimentalMaterialApi::class) @Composable fun MjImagesApp( viewModel: MjImagesViewModel )</ID>
<ID>MagicNumber:Colors.kt$0xFF1F1B16</ID>
<ID>MagicNumber:Colors.kt$0xFF3E2D16</ID>
<ID>MagicNumber:Colors.kt$0xFF452B00</ID>
Expand All @@ -37,6 +39,8 @@
<ID>MagicNumber:MjImagesApp.kt$24f</ID>
<ID>MagicNumber:String0.commonMain.kt$10</ID>
<ID>MagicNumber:String0.commonMain.kt$109</ID>
<ID>MagicNumber:String0.commonMain.kt$84</ID>
<ID>MagicNumber:String0.commonMain.kt$95</ID>
<ID>UnusedPrivateProperty:build.gradle.kts$val androidInstrumentedTest by getting { dependencies { implementation(libs.androidxUiTestJunit4) implementation(libs.androidxUiTestManifest) } }</ID>
<ID>UnusedPrivateProperty:build.gradle.kts$val jsMain by getting { dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(project(":shared")) } }</ID>
<ID>UnusedPrivateProperty:build.gradle.kts$val jvmMain by getting { dependencies { implementation(project(":shared")) implementation(compose.desktop.currentOs) } }</ID>
Expand Down
23 changes: 12 additions & 11 deletions shared/src/androidInstrumentedTest/kotlin/ui/MjImagesScreenTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ class MjImagesScreenTest {

composeTestRule.setContent {
MjImagesApp(initAppAndMockViewModel(
LocalContext.current,
EmptyMjImagesDataSource()
context = LocalContext.current,
remoteDataSource = EmptyMjImagesDataSource(),
).also { viewModel = it })
}

Expand All @@ -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()
}

Expand All @@ -72,8 +73,8 @@ class MjImagesScreenTest {

composeTestRule.setContent {
MjImagesApp(initAppAndMockViewModel(
LocalContext.current,
SuccessMjImagesDataSource()
context = LocalContext.current,
remoteDataSource = SuccessMjImagesDataSource(),
).also { viewModel = it })
}

Expand Down Expand Up @@ -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)
}
Expand Down
48 changes: 38 additions & 10 deletions shared/src/androidInstrumentedTest/kotlin/ui/ScreenTestUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
1 change: 1 addition & 0 deletions shared/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<resources>
<string name="snack_message">1) Click image to open in browser\n2) Long click to preview image</string>
<string name="failed_fetch_message">Failed to fetch images, using offline mode</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ class MjImagesRepository : KoinComponent {
fun getImages(
page: Int,
): Flow<MjImagesResponse> = 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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<MjImagesResponse?>(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_"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<MjImageResponse?>?,
val mjImageResponses: List<MjImageResponse?>? = 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
)
2 changes: 1 addition & 1 deletion shared/src/commonMain/kotlin/domain/model/MjImages.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
35 changes: 23 additions & 12 deletions shared/src/commonMain/kotlin/ui/MjImagesApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
}
}

Expand All @@ -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(
Expand Down
Loading

0 comments on commit 769afd4

Please sign in to comment.