From 197e16d1fbe05618b6a8f8ebfdf5b4a5b1bddf90 Mon Sep 17 00:00:00 2001 From: SIKV Date: Sun, 2 Jun 2024 14:18:25 +0300 Subject: [PATCH] Migrate curated photos to Compose --- .../sikv/photos/common/ui/Composables.kt | 2 + .../photos/common/ui/ContextExtensions.kt | 13 ++ .../common/ActivityPermissionManager.kt | 36 ++++ .../sikv/photos/common/PermissionManager.kt | 1 + compose-ui/.gitignore | 1 + compose-ui/build.gradle.kts | 24 +++ compose-ui/src/main/AndroidManifest.xml | 2 + .../sikv/photos/compose/ui/FavoriteButton.kt | 83 ++++++++ .../sikv/photos/compose/ui/PhotoItem.kt | 154 ++++++++++++++ .../photos/compose/ui/PhotoItemCompact.kt | 31 +++ .../github/sikv/photos/compose/ui/Scaffold.kt | 37 ++++ .../github/sikv/photos/compose/ui/Spacing.kt | 8 + .../ic_favorite_border_white_24dp.xml | 9 + .../res/drawable/ic_favorite_red_24dp.xml | 9 + .../res/drawable/ic_file_download_24dp.xml | 13 ++ compose-ui/src/main/res/values/strings.xml | 7 + .../data/repository/FavoritesRepository.kt | 2 +- .../data/repository/FavoritesRepository2.kt | 19 ++ .../data/repository/RepositoryModule.kt | 4 + .../impl/FavoritesRepository2Impl.kt | 72 +++++++ feature/curated-photos/build.gradle | 13 ++ .../photos/curated/CuratedPhotosFragment.kt | 199 ++---------------- .../photos/curated/CuratedPhotosScreen.kt | 120 +++++++++++ .../photos/curated/CuratedPhotosViewModel.kt | 80 ++++++- .../res/layout/fragment_curated_photos.xml | 42 ---- .../src/main/res/menu/menu_photos.xml | 15 -- .../src/main/res/values/strings.xml | 1 + .../photo/details/PhotoDetailsScreen.kt | 1 + gradle/libs.versions.toml | 12 +- .../photo/list/ui/PhotoActionDispatcher.kt | 2 +- photo-usecase/.gitignore | 1 + photo-usecase/build.gradle.kts | 24 +++ photo-usecase/src/main/AndroidManifest.xml | 2 + .../photo/usecase/DownloadPhotoUseCase.kt | 55 +++++ .../sikv/photo/usecase/PhotoActionsUseCase.kt | 47 +++++ photo-usecase/src/main/res/values/strings.xml | 13 ++ settings.gradle.kts | 2 + 37 files changed, 901 insertions(+), 255 deletions(-) create mode 100644 common/src/main/java/com/github/sikv/photos/common/ActivityPermissionManager.kt create mode 100644 compose-ui/.gitignore create mode 100644 compose-ui/build.gradle.kts create mode 100644 compose-ui/src/main/AndroidManifest.xml create mode 100644 compose-ui/src/main/java/com/github/sikv/photos/compose/ui/FavoriteButton.kt create mode 100644 compose-ui/src/main/java/com/github/sikv/photos/compose/ui/PhotoItem.kt create mode 100644 compose-ui/src/main/java/com/github/sikv/photos/compose/ui/PhotoItemCompact.kt create mode 100644 compose-ui/src/main/java/com/github/sikv/photos/compose/ui/Scaffold.kt create mode 100644 compose-ui/src/main/java/com/github/sikv/photos/compose/ui/Spacing.kt create mode 100644 compose-ui/src/main/res/drawable/ic_favorite_border_white_24dp.xml create mode 100644 compose-ui/src/main/res/drawable/ic_favorite_red_24dp.xml create mode 100644 compose-ui/src/main/res/drawable/ic_file_download_24dp.xml create mode 100644 compose-ui/src/main/res/values/strings.xml create mode 100644 data/src/main/java/com/github/sikv/photos/data/repository/FavoritesRepository2.kt create mode 100644 data/src/main/java/com/github/sikv/photos/data/repository/impl/FavoritesRepository2Impl.kt create mode 100644 feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosScreen.kt delete mode 100644 feature/curated-photos/src/main/res/layout/fragment_curated_photos.xml delete mode 100644 feature/curated-photos/src/main/res/menu/menu_photos.xml create mode 100644 photo-usecase/.gitignore create mode 100644 photo-usecase/build.gradle.kts create mode 100644 photo-usecase/src/main/AndroidManifest.xml create mode 100644 photo-usecase/src/main/java/com/github/sikv/photo/usecase/DownloadPhotoUseCase.kt create mode 100644 photo-usecase/src/main/java/com/github/sikv/photo/usecase/PhotoActionsUseCase.kt create mode 100644 photo-usecase/src/main/res/values/strings.xml diff --git a/common-ui/src/main/java/com/github/sikv/photos/common/ui/Composables.kt b/common-ui/src/main/java/com/github/sikv/photos/common/ui/Composables.kt index 6e02102b..b5ec2d75 100644 --- a/common-ui/src/main/java/com/github/sikv/photos/common/ui/Composables.kt +++ b/common-ui/src/main/java/com/github/sikv/photos/common/ui/Composables.kt @@ -31,6 +31,8 @@ import com.skydoves.landscapist.animation.circular.CircularRevealPlugin import com.skydoves.landscapist.components.rememberImageComponent import com.skydoves.landscapist.glide.GlideImage +// TODO: Move to compose-ui module. + @Composable fun TransparentTopAppBar( onBackPressed: () -> Unit, diff --git a/common-ui/src/main/java/com/github/sikv/photos/common/ui/ContextExtensions.kt b/common-ui/src/main/java/com/github/sikv/photos/common/ui/ContextExtensions.kt index a945d290..ff32f4ef 100644 --- a/common-ui/src/main/java/com/github/sikv/photos/common/ui/ContextExtensions.kt +++ b/common-ui/src/main/java/com/github/sikv/photos/common/ui/ContextExtensions.kt @@ -4,11 +4,13 @@ import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.net.Uri import android.provider.Settings import android.view.View import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity import androidx.browser.customtabs.CustomTabsIntent fun Context.showSoftInput(view: View): Boolean { @@ -43,6 +45,17 @@ fun Context.openAppSettings() { startActivity(intent) } +fun Context.findActivity(): AppCompatActivity { + var context = this + while (context is ContextWrapper) { + if (context is AppCompatActivity) { + return context + } + context = context.baseContext + } + throw IllegalStateException("AppCompatActivity not found") +} + fun Context.copyText(label: String, text: String) { val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipData = ClipData.newPlainText(label, text) diff --git a/common/src/main/java/com/github/sikv/photos/common/ActivityPermissionManager.kt b/common/src/main/java/com/github/sikv/photos/common/ActivityPermissionManager.kt new file mode 100644 index 00000000..a140f8d1 --- /dev/null +++ b/common/src/main/java/com/github/sikv/photos/common/ActivityPermissionManager.kt @@ -0,0 +1,36 @@ +package com.github.sikv.photos.common + +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import java.util.UUID + +class ActivityPermissionManager( + private val activity: AppCompatActivity +) : DefaultLifecycleObserver { + + private val key = UUID.randomUUID().toString() + private lateinit var requestPermission: ActivityResultLauncher + + private var onPermissionRequestResult: ((Boolean) -> Unit)? = null + + init { + activity.lifecycle.addObserver(this) + } + + override fun onCreate(owner: LifecycleOwner) { + super.onCreate(owner) + + requestPermission = activity.activityResultRegistry + .register(key, ActivityResultContracts.RequestPermission()) { granted -> + onPermissionRequestResult?.invoke(granted) + } + } + + fun requestPermission(permission: String, onPermissionRequestResult: (Boolean) -> Unit) { + this.onPermissionRequestResult = onPermissionRequestResult + requestPermission.launch(permission) + } +} diff --git a/common/src/main/java/com/github/sikv/photos/common/PermissionManager.kt b/common/src/main/java/com/github/sikv/photos/common/PermissionManager.kt index 3ffbb46d..21b275a8 100644 --- a/common/src/main/java/com/github/sikv/photos/common/PermissionManager.kt +++ b/common/src/main/java/com/github/sikv/photos/common/PermissionManager.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import java.util.* +@Deprecated("Use ActivityPermissionManager") class PermissionManager( private val fragment: Fragment ) : DefaultLifecycleObserver { diff --git a/compose-ui/.gitignore b/compose-ui/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/compose-ui/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/compose-ui/build.gradle.kts b/compose-ui/build.gradle.kts new file mode 100644 index 00000000..c5af39c2 --- /dev/null +++ b/compose-ui/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.github.sikv.photos.compose.ui" + + buildFeatures { + compose = true + viewBinding = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } +} + +dependencies { + implementation(project(":domain")) + implementation(project(":common-ui")) + + implementation(libs.androidx.compose.material3) +} diff --git a/compose-ui/src/main/AndroidManifest.xml b/compose-ui/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/compose-ui/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/FavoriteButton.kt b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/FavoriteButton.kt new file mode 100644 index 00000000..2bb65b26 --- /dev/null +++ b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/FavoriteButton.kt @@ -0,0 +1,83 @@ +package com.github.sikv.photos.compose.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import com.github.sikv.photos.common.ui.ActionIconButton + +private const val favoriteAnimationDuration = 100 +private const val unFavoriteAnimationDuration = 400 + +@Composable +fun FavoriteButton( + isFavorite: Boolean, + onToggleFavorite: () -> Unit +) { + val scale = remember { Animatable(1.0f) } + val offsetX = remember { Animatable(0f) } + + LaunchedEffect(isFavorite) { + if (isFavorite) { + scale.animateTo( + targetValue = 1.3f, + animationSpec = tween(favoriteAnimationDuration, easing = LinearEasing) + ) + scale.animateTo( + targetValue = 1f, + animationSpec = tween(favoriteAnimationDuration, easing = LinearEasing) + ) + } else { + // Source: https://ophilia.in/creating-a-wiggle-animation-in-jetpack-compose + offsetX.animateTo( + targetValue = 0f, + animationSpec = keyframes { + for (i in 1..8) { + val x = when (i % 3) { + 0 -> 2f + 1 -> -2f + else -> 0f + } + x at unFavoriteAnimationDuration / 10 * i with LinearEasing + } + } + ) + } + } + + val icon = + if (isFavorite) R.drawable.ic_favorite_red_24dp + else R.drawable.ic_favorite_border_white_24dp + + val tintColor = + if (isFavorite) colorResource(id = R.color.colorRed) + else LocalContentColor.current + + val tint: Color by animateColorAsState( + targetValue = tintColor, + animationSpec = tween(favoriteAnimationDuration), + label = "Favorite button color animation", + ) + + ActionIconButton( + icon = icon, + contentDescription = R.string.toggle_favorite, + iconTint = tint, + onClick = onToggleFavorite, + modifier = Modifier + .scale(scale.value) + .offset(x = offsetX.value.dp, y = 0.dp) + ) +} diff --git a/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/PhotoItem.kt b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/PhotoItem.kt new file mode 100644 index 00000000..005c99a6 --- /dev/null +++ b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/PhotoItem.kt @@ -0,0 +1,154 @@ +package com.github.sikv.photos.compose.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.sikv.photos.common.ui.ActionIconButton +import com.github.sikv.photos.common.ui.NetworkImage +import com.github.sikv.photos.common.ui.PlaceholderImage +import com.github.sikv.photos.common.ui.getAttributionPlaceholderBackgroundColor +import com.github.sikv.photos.common.ui.getAttributionPlaceholderTextColor +import com.github.sikv.photos.domain.Photo + +@Composable +fun PhotoItem( + photo: Photo, + isFavorite: Boolean, + onClick: () -> Unit, + onAttributionClick: () -> Unit, + onMoreClick: () -> Unit, + onToggleFavorite: () -> Unit, + onShareClick: () -> Unit, + onDownloadClick: () -> Unit +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(Spacing.One)) + Attribution( + photo = photo, + onAttributionClick = onAttributionClick + ) + IconButton( + modifier = Modifier + .background(Color.Transparent, shape = CircleShape), + onClick = onMoreClick + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(id = R.string.more) + ) + } + } + + Spacer(modifier = Modifier.height(Spacing.One)) + + NetworkImage( + imageUrl = photo.getPhotoPreviewUrl(), + loading = { + Box( + modifier = Modifier + .aspectRatio(1f) + .background(colorResource(id = R.color.colorPlaceholder)) + ) + }, + modifier = Modifier + .aspectRatio(1f) + .clickable(onClick = onClick) + ) + Row { + FavoriteButton( + isFavorite = isFavorite, + onToggleFavorite = onToggleFavorite + ) + IconButton( + modifier = Modifier + .background(Color.Transparent, shape = CircleShape), + onClick = onShareClick + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(id = R.string.share) + ) + } + Spacer(modifier = Modifier.weight(1f)) + ActionIconButton( + icon = R.drawable.ic_file_download_24dp, + contentDescription = R.string.download, + onClick = onDownloadClick + ) + } + + Spacer(modifier = Modifier.height(Spacing.Two)) + } +} + +@Composable +private fun RowScope.Attribution( + photo: Photo, + onAttributionClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = onAttributionClick) + .padding(end = Spacing.One) + .weight(1f) + ) { + val photographerImageUrl = photo.getPhotoPhotographerImageUrl() + + val modifier = Modifier + .size(36.dp) + .clip(CircleShape) + + if (photographerImageUrl != null) { + NetworkImage( + imageUrl = photographerImageUrl, + modifier = modifier + ) + } else { + PlaceholderImage( + text = photo.getPhotoPhotographerName().first().uppercaseChar().toString(), + textColor = getAttributionPlaceholderTextColor(LocalContext.current), + backgroundColor = getAttributionPlaceholderBackgroundColor(LocalContext.current), + modifier = modifier + ) + } + Spacer(modifier = Modifier.width(Spacing.One)) + Column { + Text(photo.getPhotoPhotographerName(), + style = MaterialTheme.typography.titleSmall + ) + Text(photo.getPhotoSource().title, + style = MaterialTheme.typography.bodySmall + ) + } + } +} diff --git a/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/PhotoItemCompact.kt b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/PhotoItemCompact.kt new file mode 100644 index 00000000..fcded1b3 --- /dev/null +++ b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/PhotoItemCompact.kt @@ -0,0 +1,31 @@ +package com.github.sikv.photos.compose.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import com.github.sikv.photos.common.ui.NetworkImage +import com.github.sikv.photos.domain.Photo + +@Composable +fun PhotoItemCompact( + photo: Photo, + onClick: () -> Unit +) { + NetworkImage( + imageUrl = photo.getPhotoPreviewUrl(), + loading = { + Box( + modifier = Modifier + .aspectRatio(1f) + .background(colorResource(id = R.color.colorPlaceholder)) + ) + }, + modifier = Modifier + .aspectRatio(1f) + .clickable(onClick = onClick) + ) +} diff --git a/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/Scaffold.kt b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/Scaffold.kt new file mode 100644 index 00000000..0a3c2e2b --- /dev/null +++ b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/Scaffold.kt @@ -0,0 +1,37 @@ +package com.github.sikv.photos.compose.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Scaffold( + modifier: Modifier = Modifier, + title: String, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable BoxScope.() -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = title) }, + actions = actions + ) + }, + modifier = modifier + ) { padding -> + Box( + content = content, + modifier = Modifier + .padding(padding) // TODO: Fix bottom padding. + ) + } +} diff --git a/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/Spacing.kt b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/Spacing.kt new file mode 100644 index 00000000..132e9eeb --- /dev/null +++ b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/Spacing.kt @@ -0,0 +1,8 @@ +package com.github.sikv.photos.compose.ui + +import androidx.compose.ui.unit.dp + +object Spacing { + val One = 8.dp + val Two = 16.dp +} diff --git a/compose-ui/src/main/res/drawable/ic_favorite_border_white_24dp.xml b/compose-ui/src/main/res/drawable/ic_favorite_border_white_24dp.xml new file mode 100644 index 00000000..d8100d32 --- /dev/null +++ b/compose-ui/src/main/res/drawable/ic_favorite_border_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/compose-ui/src/main/res/drawable/ic_favorite_red_24dp.xml b/compose-ui/src/main/res/drawable/ic_favorite_red_24dp.xml new file mode 100644 index 00000000..b629d667 --- /dev/null +++ b/compose-ui/src/main/res/drawable/ic_favorite_red_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/compose-ui/src/main/res/drawable/ic_file_download_24dp.xml b/compose-ui/src/main/res/drawable/ic_file_download_24dp.xml new file mode 100644 index 00000000..3140c274 --- /dev/null +++ b/compose-ui/src/main/res/drawable/ic_file_download_24dp.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/compose-ui/src/main/res/values/strings.xml b/compose-ui/src/main/res/values/strings.xml new file mode 100644 index 00000000..d85896a3 --- /dev/null +++ b/compose-ui/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Toggle favorite + Share + Download + More + diff --git a/data/src/main/java/com/github/sikv/photos/data/repository/FavoritesRepository.kt b/data/src/main/java/com/github/sikv/photos/data/repository/FavoritesRepository.kt index 5e7c5ebf..ad014a9e 100644 --- a/data/src/main/java/com/github/sikv/photos/data/repository/FavoritesRepository.kt +++ b/data/src/main/java/com/github/sikv/photos/data/repository/FavoritesRepository.kt @@ -5,7 +5,7 @@ import com.github.sikv.photos.data.SortBy import com.github.sikv.photos.data.persistence.FavoritePhotoEntity import kotlinx.coroutines.flow.Flow -// TODO: This repository should be refactored (simplified). +@Deprecated("Use FavoritesRepository2") interface FavoritesRepository { sealed interface Update diff --git a/data/src/main/java/com/github/sikv/photos/data/repository/FavoritesRepository2.kt b/data/src/main/java/com/github/sikv/photos/data/repository/FavoritesRepository2.kt new file mode 100644 index 00000000..fd5c9d44 --- /dev/null +++ b/data/src/main/java/com/github/sikv/photos/data/repository/FavoritesRepository2.kt @@ -0,0 +1,19 @@ +package com.github.sikv.photos.data.repository + +import com.github.sikv.photos.data.SortBy +import com.github.sikv.photos.data.persistence.FavoritePhotoEntity +import com.github.sikv.photos.domain.Photo +import kotlinx.coroutines.flow.Flow + +interface FavoritesRepository2 { + + suspend fun isFavorite(photo: Photo): Boolean + suspend fun invertFavorite(photo: Photo) + + fun getFavorites(sortBy: SortBy = SortBy.DATE_ADDED_NEWEST): Flow> + fun getRandom(): FavoritePhotoEntity? + + suspend fun markAllAsDeleted(): Boolean + suspend fun unmarkAllAsDeleted() + suspend fun deleteAllMarked() +} diff --git a/data/src/main/java/com/github/sikv/photos/data/repository/RepositoryModule.kt b/data/src/main/java/com/github/sikv/photos/data/repository/RepositoryModule.kt index 162ab738..855592bf 100644 --- a/data/src/main/java/com/github/sikv/photos/data/repository/RepositoryModule.kt +++ b/data/src/main/java/com/github/sikv/photos/data/repository/RepositoryModule.kt @@ -1,5 +1,6 @@ package com.github.sikv.photos.data.repository +import com.github.sikv.photos.data.repository.impl.FavoritesRepository2Impl import com.github.sikv.photos.data.repository.impl.FavoritesRepositoryImpl import com.github.sikv.photos.data.repository.impl.PhotosRepositoryImpl import dagger.Binds @@ -16,4 +17,7 @@ abstract class RepositoryModule { @Binds abstract fun bindFavoritesRepository(favoritesRepository: FavoritesRepositoryImpl): FavoritesRepository + + @Binds + abstract fun bindFavorites2Repository(favoritesRepository: FavoritesRepository2Impl): FavoritesRepository2 } diff --git a/data/src/main/java/com/github/sikv/photos/data/repository/impl/FavoritesRepository2Impl.kt b/data/src/main/java/com/github/sikv/photos/data/repository/impl/FavoritesRepository2Impl.kt new file mode 100644 index 00000000..b482558f --- /dev/null +++ b/data/src/main/java/com/github/sikv/photos/data/repository/impl/FavoritesRepository2Impl.kt @@ -0,0 +1,72 @@ +package com.github.sikv.photos.data.repository.impl + +import com.github.sikv.photos.data.SortBy +import com.github.sikv.photos.data.persistence.FavoritePhotoEntity +import com.github.sikv.photos.data.persistence.FavoritesDao +import com.github.sikv.photos.data.persistence.FavoritesDbQueryBuilder +import com.github.sikv.photos.data.repository.FavoritesRepository +import com.github.sikv.photos.data.repository.FavoritesRepository2 +import com.github.sikv.photos.domain.Photo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FavoritesRepository2Impl @Inject constructor( + private val favoritesDao: FavoritesDao, + private val queryBuilder: FavoritesDbQueryBuilder +) : FavoritesRepository2 { + + override suspend fun isFavorite(photo: Photo): Boolean = + withContext(Dispatchers.IO) { + favoritesDao.getById(photo.getPhotoId()) != null + } + + override suspend fun invertFavorite(photo: Photo) { + withContext(Dispatchers.IO) { + val favorite = !isFavorite(photo) + val favoritePhoto = FavoritePhotoEntity.fromPhoto(photo) + + if (favorite) { + favoritesDao.insert(favoritePhoto) + } else { + favoritesDao.delete(favoritePhoto) + } + } + } + + override fun getFavorites(sortBy: SortBy): Flow> { + val query = queryBuilder.buildGetPhotosQuery(sortBy) + return favoritesDao.getPhotos(query).map { photos -> + photos.onEach { it.favorite = true } + } + } + + override fun getRandom(): FavoritePhotoEntity? = favoritesDao.getRandom() + + override suspend fun markAllAsDeleted(): Boolean = withContext(Dispatchers.IO) { + val count = favoritesDao.getCount() + + if (count > 0) { + favoritesDao.markAllAsDeleted() + true + } else { + false + } + } + + override suspend fun unmarkAllAsDeleted() = withContext(Dispatchers.IO) { + favoritesDao.unmarkAllAsDeleted() + } + + override suspend fun deleteAllMarked() = withContext(Dispatchers.IO) { + favoritesDao.deleteAllMarked() + } +} diff --git a/feature/curated-photos/build.gradle b/feature/curated-photos/build.gradle index 91a57ce1..3ae5755c 100644 --- a/feature/curated-photos/build.gradle +++ b/feature/curated-photos/build.gradle @@ -9,8 +9,13 @@ android { namespace 'com.github.sikv.photos.curated' buildFeatures { + compose true viewBinding true } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.composeCompiler.get() + } } dependencies { @@ -19,15 +24,23 @@ dependencies { implementation project(':config') implementation project(':common') implementation project(':common-ui') + implementation project(':compose-ui') implementation project(':navigation') implementation project(':photo-list-ui') + implementation project(':photo-usecase') implementation libs.material implementation libs.androidx.fragment + implementation libs.androidx.compose.material3 + implementation libs.accompanist.themeadapter.material3 + implementation libs.androidx.lifecycle.viewmodel.compose + implementation libs.androidx.lifecycle.runtime.compose + implementation libs.inject kapt libs.hilt.compiler implementation libs.hilt.android implementation libs.androidx.paging.runtime + implementation libs.androidx.paging.compose } diff --git a/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosFragment.kt b/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosFragment.kt index 4fae1bee..9e211abc 100644 --- a/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosFragment.kt +++ b/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosFragment.kt @@ -1,210 +1,41 @@ package com.github.sikv.photos.curated -import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup -import androidx.core.view.isVisible +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.paging.LoadState -import com.github.sikv.photo.list.ui.PhotoActionDispatcher -import com.github.sikv.photo.list.ui.PhotoItemLayoutType -import com.github.sikv.photo.list.ui.adapter.PhotoPagingAdapter -import com.github.sikv.photo.list.ui.setItemLayoutType -import com.github.sikv.photo.list.ui.updateLoadState -import com.github.sikv.photos.common.DownloadService -import com.github.sikv.photos.common.PhotoLoader -import com.github.sikv.photos.common.ui.* -import com.github.sikv.photos.common.ui.toolbar.FragmentToolbar -import com.github.sikv.photos.curated.databinding.FragmentCuratedPhotosBinding -import com.github.sikv.photos.data.repository.FavoritesRepository -import com.github.sikv.photos.domain.ListLayout -import com.github.sikv.photos.domain.Photo +import com.github.sikv.photos.common.ui.BaseFragment +import com.github.sikv.photos.navigation.args.PhotoDetailsFragmentArguments import com.github.sikv.photos.navigation.route.PhotoDetailsRoute -import com.github.sikv.photos.navigation.route.SetWallpaperRoute +import com.google.accompanist.themeadapter.material3.Mdc3Theme import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint class CuratedPhotosFragment : BaseFragment() { - // TODO: This is used only for PhotoPagingAdapter. - // PhotoPagingAdapter should be refactored to not use FavoritesRepository. - @Inject - lateinit var favoritesRepository: FavoritesRepository - - // TODO: This is used only for PhotoActionDispatcher. - // PhotoActionDispatcher should be refactored to inject DownloadService directly. - @Inject - lateinit var downloadService: DownloadService - - // TODO: Same as for DownloadService. - @Inject - lateinit var photoLoader: PhotoLoader - @Inject lateinit var photoDetailsRoute: PhotoDetailsRoute - // TODO: Same as for DownloadService. - @Inject - lateinit var setWallpaperRoute: SetWallpaperRoute - private val viewModel: CuratedPhotosViewModel by viewModels() - private val photoActionDispatcher by lazy { - PhotoActionDispatcher( - fragment = this, - downloadService = downloadService, - photoLoader = photoLoader, - photoDetailsRoute = photoDetailsRoute, - setWallpaperRoute = setWallpaperRoute, - onToggleFavorite = viewModel::toggleFavorite, - onShowMessage = ::showMessage - ) - } - - private lateinit var photoAdapter: PhotoPagingAdapter - - private var _binding: FragmentCuratedPhotosBinding? = null - private val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - photoAdapter = PhotoPagingAdapter( - photoLoader = photoLoader, - favoritesRepository = favoritesRepository, - lifecycleScope = lifecycleScope, - listener = photoActionDispatcher - ) - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentCuratedPhotosBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupToolbar(R.string.photos) - - binding.photosRecycler.adapter = photoAdapter - binding.photosRecycler.disableChangeAnimations() - - binding.loadingView.isVisible = false - binding.loadingErrorView.isVisible = false - - binding.loadingErrorView.setTryAgainClickListener { - photoAdapter.retry() - } - - addLoadStateListener() - collect() - } - - override fun onDestroyView() { - super.onDestroyView() - - _binding = null - } - - override fun onCreateToolbar(): FragmentToolbar { - return FragmentToolbar.Builder() - .withId(R.id.toolbar) - .withMenu(R.menu.menu_photos) - .withMenuItems( - listOf( - R.id.itemViewList, - R.id.itemViewGrid - ), - listOf( - object : MenuItem.OnMenuItemClickListener { - override fun onMenuItemClick(menuItem: MenuItem): Boolean { - viewModel.updateListLayout(ListLayout.LIST) - return true - } - }, - - object : MenuItem.OnMenuItemClickListener { - override fun onMenuItemClick(menuItem: MenuItem): Boolean { - viewModel.updateListLayout(ListLayout.GRID) - return true - } - } - ) + return ComposeView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT ) - .build() - } - - override fun onScrollToTop() { - binding.photosRecycler.scrollToTop() - } - - @SuppressLint("NotifyDataSetChanged") - private fun collect() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.getCuratedPhotos().collect { - photoAdapter.submitData(lifecycle, it) - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.listLayoutState.collect(::updateListLayout) - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.favoriteUpdates().collect { update -> - when (update) { - is FavoritesRepository.UpdatePhoto -> { - photoAdapter.notifyPhotoChanged(update.photo) - } - is FavoritesRepository.UpdateAll -> { - photoAdapter.notifyDataSetChanged() - } - } + setContent { + Mdc3Theme { + CuratedPhotosScreen( + viewModel = viewModel, + onOpenPhotoDetails = { photo -> + photoDetailsRoute.present(navigation, PhotoDetailsFragmentArguments(photo)) + }) } } } } - - private fun addLoadStateListener() { - photoAdapter.addLoadStateListener { loadState -> - when (loadState.refresh) { - is LoadState.NotLoading -> { - binding.photosRecycler.setVisibilityAnimated(View.VISIBLE) - } - is LoadState.Loading -> { - binding.photosRecycler.setVisibilityAnimated(View.GONE) - } - is LoadState.Error -> { - binding.photosRecycler.setVisibilityAnimated(View.GONE) - } - } - - binding.loadingView.updateLoadState(loadState) - binding.loadingErrorView.updateLoadState(loadState) - } - } - - private fun updateListLayout(listLayout: ListLayout) { - val itemLayoutType = PhotoItemLayoutType.findBySpanCount(listLayout.spanCount) - - photoAdapter.setItemLayoutType(itemLayoutType) - binding.photosRecycler.setItemLayoutType(itemLayoutType) - - setMenuItemVisibility(R.id.itemViewList, listLayout == ListLayout.GRID) - setMenuItemVisibility(R.id.itemViewGrid, listLayout == ListLayout.LIST) - } } diff --git a/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosScreen.kt b/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosScreen.kt new file mode 100644 index 00000000..0c5bed51 --- /dev/null +++ b/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosScreen.kt @@ -0,0 +1,120 @@ +package com.github.sikv.photos.curated + +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import com.github.sikv.photos.common.ui.findActivity +import com.github.sikv.photos.compose.ui.PhotoItem +import com.github.sikv.photos.compose.ui.PhotoItemCompact +import com.github.sikv.photos.compose.ui.Scaffold +import com.github.sikv.photos.domain.ListLayout +import com.github.sikv.photos.domain.Photo + +// TODO: Add scrollable toolbar. +// TODO: Add loading indicator. + +@Composable +internal fun CuratedPhotosScreen( + viewModel: CuratedPhotosViewModel, + onOpenPhotoDetails: (Photo) -> Unit, +) { + val photos = viewModel.getCuratedPhotos().collectAsLazyPagingItems() + val listLayout by viewModel.listLayoutState.collectAsStateWithLifecycle() + + Scaffold( + title = stringResource(id = R.string.photos), + actions = { + SwitchLayoutAction(viewModel = viewModel) + } + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(listLayout.spanCount) + ) { + items(photos.itemCount) { index -> + photos[index]?.let { photo -> + Photo( + photo = photo, + listLayout = listLayout, + viewModel = viewModel, + onOpenPhotoDetails = onOpenPhotoDetails + ) + } + } + } + } +} + +@Composable +private fun Photo( + photo: Photo, + listLayout: ListLayout, + viewModel: CuratedPhotosViewModel, + onOpenPhotoDetails: (Photo) -> Unit +) { + val context = LocalContext.current + val isFavorite by viewModel.isFavorite(photo).collectAsStateWithLifecycle(initialValue = false) + + when (listLayout) { + ListLayout.LIST -> { + PhotoItem( + photo = photo, + isFavorite = isFavorite, + onClick = { + onOpenPhotoDetails(photo) + }, + onAttributionClick = { + viewModel.onPhotoAttributionClick(context.findActivity(), photo) + }, + onMoreClick = { + viewModel.openActions(context.findActivity(), photo) + }, + onToggleFavorite = { + viewModel.toggleFavorite(photo) + }, + onShareClick = { + viewModel.sharePhoto(context.findActivity(), photo) + }, + onDownloadClick = { + viewModel.downloadPhoto(context.findActivity(), photo) + } + ) + } + ListLayout.GRID -> { + PhotoItemCompact( + photo = photo, + onClick = { + onOpenPhotoDetails(photo) + } + ) + } + } +} + +@Composable +private fun SwitchLayoutAction( + viewModel: CuratedPhotosViewModel +) { + val listLayout by viewModel.listLayoutState.collectAsStateWithLifecycle() + + val icon = when (listLayout) { + ListLayout.LIST -> R.drawable.ic_view_grid_24dp + ListLayout.GRID -> R.drawable.ic_view_list_24dp + } + + IconButton( + onClick = viewModel::switchListLayout + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = stringResource(id = R.string.switch_layout) + ) + } +} diff --git a/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosViewModel.kt b/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosViewModel.kt index fdcee29d..6ecb8f71 100644 --- a/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosViewModel.kt +++ b/feature/curated-photos/src/main/java/com/github/sikv/photos/curated/CuratedPhotosViewModel.kt @@ -1,43 +1,101 @@ package com.github.sikv.photos.curated +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import com.github.sikv.photo.usecase.DownloadPhotoUseCase +import com.github.sikv.photo.usecase.PhotoActionsUseCase +import com.github.sikv.photos.common.PreferencesService +import com.github.sikv.photos.common.ui.openUrl import com.github.sikv.photos.config.ConfigProvider +import com.github.sikv.photos.data.createShareIntent +import com.github.sikv.photos.data.repository.FavoritesRepository2 +import com.github.sikv.photos.data.repository.PhotosRepository import com.github.sikv.photos.domain.ListLayout import com.github.sikv.photos.domain.Photo -import com.github.sikv.photos.common.PreferencesService -import com.github.sikv.photos.data.repository.FavoritesRepository -import com.github.sikv.photos.data.repository.PhotosRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel internal class CuratedPhotosViewModel @Inject constructor( private val photosRepository: PhotosRepository, - private val favoritesRepository: FavoritesRepository, + private val favoritesRepository: FavoritesRepository2, private val preferencesService: PreferencesService, - private val configProvider: ConfigProvider + private val configProvider: ConfigProvider, + private val photoActionsUseCase: PhotoActionsUseCase, + private val downloadPhotoUseCase: DownloadPhotoUseCase ) : ViewModel() { private val mutableListLayoutState = MutableStateFlow(preferencesService.getCuratedListLayout()) val listLayoutState: StateFlow = mutableListLayoutState - fun favoriteUpdates(): Flow { - return favoritesRepository.favoriteUpdates() + fun isFavorite(photo: Photo): Flow { + return favoritesRepository.getFavorites() + .map { photos -> + photos.contains(photo) + } } fun toggleFavorite(photo: Photo) { - favoritesRepository.invertFavorite(photo) + viewModelScope.launch { + favoritesRepository.invertFavorite(photo) + } + } + + // TODO: This is temporary solution! + // Will be refactored after migration to Compose Navigation is finished. + fun onPhotoAttributionClick(activity: AppCompatActivity, photo: Photo) { + photo.getPhotoPhotographerUrl()?.let { photographerUrl -> + activity.openUrl(photographerUrl) + } ?: run { + activity.openUrl(photo.getPhotoShareUrl()) + } } - fun updateListLayout(listLayout: ListLayout) { - preferencesService.setCuratedListLayout(listLayout) - mutableListLayoutState.value = listLayout + // TODO: This is temporary solution! + // Will be refactored after migration to Compose Navigation is finished. + fun openActions(activity: AppCompatActivity, photo: Photo) { + photoActionsUseCase.openActions(activity.supportFragmentManager, photo) { message -> + Toast.makeText(activity, message, Toast.LENGTH_SHORT) + .show() + } + } + + // TODO: This is temporary solution! + // Will be refactored after migration to Compose Navigation is finished. + fun downloadPhoto(activity: AppCompatActivity, photo: Photo) { + downloadPhotoUseCase.download(activity, photo) { message -> + Toast.makeText(activity, message, Toast.LENGTH_SHORT) + .show() + } + } + + // TODO: This is temporary solution! + // Will be refactored after migration to Compose Navigation is finished. + fun sharePhoto(activity: AppCompatActivity, photo: Photo) { + activity.startActivity(photo.createShareIntent()) + } + + fun switchListLayout() { + when (listLayoutState.value) { + ListLayout.LIST -> { + preferencesService.setCuratedListLayout(ListLayout.GRID) + mutableListLayoutState.value = ListLayout.GRID + } + ListLayout.GRID -> { + preferencesService.setCuratedListLayout(ListLayout.LIST) + mutableListLayoutState.value = ListLayout.LIST + } + } } fun getCuratedPhotos(): Flow> { diff --git a/feature/curated-photos/src/main/res/layout/fragment_curated_photos.xml b/feature/curated-photos/src/main/res/layout/fragment_curated_photos.xml deleted file mode 100644 index d33adb68..00000000 --- a/feature/curated-photos/src/main/res/layout/fragment_curated_photos.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/feature/curated-photos/src/main/res/menu/menu_photos.xml b/feature/curated-photos/src/main/res/menu/menu_photos.xml deleted file mode 100644 index 99183dde..00000000 --- a/feature/curated-photos/src/main/res/menu/menu_photos.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - diff --git a/feature/curated-photos/src/main/res/values/strings.xml b/feature/curated-photos/src/main/res/values/strings.xml index 461a9af6..d1894e14 100644 --- a/feature/curated-photos/src/main/res/values/strings.xml +++ b/feature/curated-photos/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Photos + Switch layout diff --git a/feature/photo-details/src/main/java/com/github/sikv/photos/photo/details/PhotoDetailsScreen.kt b/feature/photo-details/src/main/java/com/github/sikv/photos/photo/details/PhotoDetailsScreen.kt index be4000d8..8502c627 100644 --- a/feature/photo-details/src/main/java/com/github/sikv/photos/photo/details/PhotoDetailsScreen.kt +++ b/feature/photo-details/src/main/java/com/github/sikv/photos/photo/details/PhotoDetailsScreen.kt @@ -246,6 +246,7 @@ private fun SecondaryActions( } } +// TODO: Use FavoriteButton from compose-ui module. @Composable private fun FavoriteButton( isFavorite: Boolean, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70342c96..ad90a41f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,9 @@ room = "2.5.2" gms = "4.3.15" ossLicensesPlugin = "0.10.6" landscapist = "2.1.9" # Do not update until "Sealed classes are not supported as program classes" error fixed. +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" [plugins] android-application = { id = "com.android.application", version.ref = "gradle" } @@ -31,13 +34,17 @@ androidx-fragment = { group = "androidx.fragment", name = "fragment-ktx", versio androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version = "1.3.0" } androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version = "1.0.0" } androidx-browser = { group = "androidx.browser", name = "browser", version = "1.5.0" } -androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version = "3.1.1" } + +androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version = "3.3.0" } +androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version = "3.3.0" } + androidx-compose_material = { group = "androidx.compose.material", name = "material", version = "1.4.3" } androidx-compose_material3 = { group = "androidx.compose.material3", name = "material3", version = "1.1.1" } androidx-lifecycle-common = { group = "androidx.lifecycle", name = "lifecycle-common-java8", version.ref = "lifecycle" } androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } @@ -69,6 +76,9 @@ mlkit-imageLabeling = { group = "com.google.mlkit", name = "image-labeling", ver playServices-auth = { group = "com.google.android.gms", name = "play-services-auth", version = "20.5.0" } playServices-ossLicenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version = "17.0.1" } gms-oss-licenses-plugin = { module = "com.google.android.gms:oss-licenses-plugin", version.ref = "ossLicensesPlugin" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } [bundles] retrofit2 = ["retrofit2", "retrofit2-converter-gson", "okhttp3-logging"] diff --git a/photo-list-ui/src/main/java/com/github/sikv/photo/list/ui/PhotoActionDispatcher.kt b/photo-list-ui/src/main/java/com/github/sikv/photo/list/ui/PhotoActionDispatcher.kt index 51e9003c..a4b4f5f4 100644 --- a/photo-list-ui/src/main/java/com/github/sikv/photo/list/ui/PhotoActionDispatcher.kt +++ b/photo-list-ui/src/main/java/com/github/sikv/photo/list/ui/PhotoActionDispatcher.kt @@ -16,7 +16,7 @@ import com.github.sikv.photos.navigation.route.PhotoDetailsRoute import com.github.sikv.photos.navigation.route.SetWallpaperRoute import com.google.android.material.dialog.MaterialAlertDialogBuilder -// TODO: Use DI here. +@Deprecated("Use photo-usecase module") class PhotoActionDispatcher( private val fragment: BaseFragment, private val downloadService: DownloadService, diff --git a/photo-usecase/.gitignore b/photo-usecase/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/photo-usecase/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/photo-usecase/build.gradle.kts b/photo-usecase/build.gradle.kts new file mode 100644 index 00000000..974de45c --- /dev/null +++ b/photo-usecase/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.github.sikv.photo.usecase" +} + +dependencies { + implementation(project(":domain")) + implementation(project(":common")) + implementation(project(":common-ui")) + implementation(project(":data")) + implementation(project(":navigation")) + + implementation(libs.inject) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.material) + implementation(libs.androidx.lifecycle.runtime) +} diff --git a/photo-usecase/src/main/AndroidManifest.xml b/photo-usecase/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/photo-usecase/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/photo-usecase/src/main/java/com/github/sikv/photo/usecase/DownloadPhotoUseCase.kt b/photo-usecase/src/main/java/com/github/sikv/photo/usecase/DownloadPhotoUseCase.kt new file mode 100644 index 00000000..e2651620 --- /dev/null +++ b/photo-usecase/src/main/java/com/github/sikv/photo/usecase/DownloadPhotoUseCase.kt @@ -0,0 +1,55 @@ +package com.github.sikv.photo.usecase + +import android.Manifest +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import com.github.sikv.photos.common.ActivityPermissionManager +import com.github.sikv.photos.common.DownloadService +import com.github.sikv.photos.common.ui.openAppSettings +import com.github.sikv.photos.domain.Photo +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import javax.inject.Inject + +class DownloadPhotoUseCase @Inject constructor( + private val downloadService: DownloadService +) { + + fun download(activity: AppCompatActivity, photo: Photo, onShowMessage: (String) -> Unit) { + val permissionManager = ActivityPermissionManager(activity) + + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { + // No need to request WRITE_EXTERNAL_STORAGE permission on Android 11 and higher + downloadPhotoInternal(activity, photo, onShowMessage) + } else { + // Request WRITE_EXTERNAL_STORAGE permission on Android 10 and lower + permissionManager.requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) { granted -> + if (granted) { + downloadPhotoInternal(activity, photo, onShowMessage) + } else { + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.storage_permission) + .setMessage(R.string.storage_permission_description) + .setPositiveButton(R.string.open_settings) { _, _ -> + activity.openAppSettings() + } + .setNegativeButton(R.string.cancel, null) + .create() + .show() + } + } + } + } + + private fun downloadPhotoInternal( + activity: AppCompatActivity, + photo: Photo, + onShowMessage: (String) -> Unit + ) { + downloadService.downloadPhoto( + photoUrl = photo.getPhotoDownloadUrl(), + notificationTitle = activity.getString(R.string.photos), + notificationDescription = activity.getString(R.string.downloading_photo) + ) + onShowMessage(activity.getString(R.string.downloading_photo)) + } +} diff --git a/photo-usecase/src/main/java/com/github/sikv/photo/usecase/PhotoActionsUseCase.kt b/photo-usecase/src/main/java/com/github/sikv/photo/usecase/PhotoActionsUseCase.kt new file mode 100644 index 00000000..33a01232 --- /dev/null +++ b/photo-usecase/src/main/java/com/github/sikv/photo/usecase/PhotoActionsUseCase.kt @@ -0,0 +1,47 @@ +package com.github.sikv.photo.usecase + +import android.content.Context +import androidx.fragment.app.FragmentManager +import com.github.sikv.photos.common.ui.OptionsBottomSheetDialog +import com.github.sikv.photos.common.ui.copyText +import com.github.sikv.photos.domain.Photo +import com.github.sikv.photos.navigation.args.SetWallpaperFragmentArguments +import com.github.sikv.photos.navigation.route.SetWallpaperRoute +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class PhotoActionsUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val setWallpaperRoute: SetWallpaperRoute +) { + + fun openActions( + fragmentManager: FragmentManager, + photo: Photo, + onShowMessage: (String) -> Unit + ) { + val options = listOf( + context.getString(R.string.set_wallpaper), + context.getString(R.string.copy_link) + ) + + val dialog = OptionsBottomSheetDialog.newInstance(options, null) { index -> + when (index) { + // Set Wallpaper + 0 -> { + setWallpaperRoute.present(fragmentManager, SetWallpaperFragmentArguments(photo)) + } + // Copy Link + 1 -> { + val label = context.getString(R.string.photo_link) + val text = photo.getPhotoShareUrl() + + context.copyText(label, text) + onShowMessage(context.getString(R.string.link_copied)) + } + } + } + + dialog.show(fragmentManager) + } +} diff --git a/photo-usecase/src/main/res/values/strings.xml b/photo-usecase/src/main/res/values/strings.xml new file mode 100644 index 00000000..944efa4f --- /dev/null +++ b/photo-usecase/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Photos + Downloading photo… + Storage permission + Storage permission is required to download photos. + Open Settings + Cancel + Set Wallpaper + Copy Link + Photo link + Link copied + diff --git a/settings.gradle.kts b/settings.gradle.kts index bc5d8d62..62060be3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,3 +41,5 @@ include(":feature:favorites") include(":feature:preferences") include(":theme-manager") include(":account") +include(":compose-ui") +include(":photo-usecase")