simple photo viewer

This commit is contained in:
2024-07-16 10:29:37 +03:00
parent 9e09cbb640
commit 1817698031
27 changed files with 484 additions and 278 deletions
@@ -20,10 +20,14 @@ data class ChatMaterials(
}
fun NavGraphBuilder.chatMaterialsScreen(
onBack: () -> Unit
onBack: () -> Unit,
onPhotoClicked: (url: String) -> Unit
) {
composable<ChatMaterials> {
ChatMaterialsRoute(onBack = onBack)
ChatMaterialsRoute(
onBack = onBack,
onPhotoClicked = onPhotoClicked
)
}
}
@@ -14,7 +14,10 @@ import coil.compose.AsyncImage
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
@Composable
fun ChatMaterialItem(item: UiChatMaterial) {
fun ChatMaterialItem(
item: UiChatMaterial,
onClick: () -> Unit
) {
when (item) {
is UiChatMaterial.Photo -> {
AsyncImage(
@@ -61,23 +61,25 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModel
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModel
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalThemeConfig
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@Composable
fun ChatMaterialsRoute(
onBack: () -> Unit,
onPhotoClicked: (url: String) -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
) {
val userSettings: UserSettings = koinInject()
@@ -92,7 +94,8 @@ fun ChatMaterialsRoute(
onBack = onBack,
onTypeChanged = viewModel::onTypeChanged,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh
onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked
)
}
@@ -108,7 +111,8 @@ fun ChatMaterialsScreen(
onBack: () -> Unit = {},
onTypeChanged: (String) -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
@@ -318,7 +322,14 @@ fun ChatMaterialsScreen(
}
}
items(attachments) { item ->
ChatMaterialItem(item = item)
ChatMaterialItem(
item = item,
onClick = {
if (item is UiChatMaterial.Photo) {
onPhotoClicked(item.previewUrl)
}
}
)
}
repeat(3) {
item {
@@ -347,7 +358,10 @@ fun ChatMaterialsScreen(
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(attachments) { item ->
ChatMaterialItem(item = item)
ChatMaterialItem(
item = item,
onClick = {}
)
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
@@ -16,6 +16,7 @@ object Conversations
fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit,
navController: NavController,
) {
composable<Conversations> {
@@ -25,6 +26,7 @@ fun NavGraphBuilder.conversationsScreen(
ConversationsRoute(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
onPhotoClicked = onPhotoClicked,
viewModel = viewModel
)
}
@@ -1,6 +1,5 @@
package dev.meloda.fast.conversations.presentation
import android.graphics.drawable.ColorDrawable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
@@ -8,6 +7,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -41,47 +41,27 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.DotsFlashing
import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.util.getResourcePainter
import dev.meloda.fast.ui.util.getString
import dev.meloda.fast.ui.R as UiR
val BirthdayColor = Color(0xffb00b69)
@Composable
fun UiImage.getResourcePainter(): Painter? {
return when (this) {
is UiImage.Resource -> painterResource(id = resId)
else -> null
}
}
@Composable
fun UiImage.getImage(): Any {
return when (this) {
is UiImage.Color -> ColorDrawable(color)
is UiImage.ColorResource -> ColorDrawable(colorResource(id = resId).toArgb())
is UiImage.Resource -> painterResource(id = resId)
is UiImage.Simple -> drawable
is UiImage.Url -> url
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConversationItem(
@@ -92,6 +72,7 @@ fun ConversationItem(
isUserAccount: Boolean,
conversation: UiConversation,
modifier: Modifier = Modifier,
onPhotoClicked: (url: String) -> Unit
) {
val context = LocalContext.current
val hapticFeedback = LocalHapticFeedback.current
@@ -174,7 +155,12 @@ fun ConversationItem(
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
.clip(CircleShape)
.clickable {
if (avatarImage is String) {
onPhotoClicked(avatarImage)
}
},
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut)
)
}
@@ -32,7 +32,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ConversationsListComposable(
fun ConversationsList(
onConversationsClick: (Int) -> Unit,
onConversationsLongClick: (UiConversation) -> Unit,
screenState: ConversationsScreenState,
@@ -40,7 +40,8 @@ fun ConversationsListComposable(
maxLines: Int,
modifier: Modifier,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
padding: PaddingValues
padding: PaddingValues,
onPhotoClicked: (url: String) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
@@ -72,7 +73,8 @@ fun ConversationsListComposable(
maxLines = maxLines,
isUserAccount = isUserAccount,
conversation = conversation,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
onPhotoClicked = onPhotoClicked
)
Spacer(modifier = Modifier.height(8.dp))
@@ -66,6 +66,10 @@ import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.ConversationsViewModelImpl
import dev.meloda.fast.conversations.model.ConversationOption
@@ -80,10 +84,6 @@ import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.isScrollingUp
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@@ -93,6 +93,7 @@ import dev.meloda.fast.ui.R as UiR
fun ConversationsRoute(
onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit,
viewModel: ConversationsViewModel = koinViewModel<ConversationsViewModelImpl>()
) {
val context = LocalContext.current
@@ -130,7 +131,8 @@ fun ConversationsRoute(
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh
onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked
)
@@ -156,7 +158,8 @@ fun ConversationsScreen(
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}
) {
val view = LocalView.current
val currentTheme = LocalThemeConfig.current
@@ -349,7 +352,7 @@ fun ConversationsScreen(
} else Modifier
)
) {
ConversationsListComposable(
ConversationsList(
onConversationsClick = onConversationItemClicked,
onConversationsLongClick = onConversationItemLongClicked,
screenState = screenState,
@@ -364,7 +367,8 @@ fun ConversationsScreen(
Modifier
}.fillMaxSize(),
onOptionClicked = onOptionClicked,
padding = padding
padding = padding,
onPhotoClicked = onPhotoClicked
)
if (enablePullToRefresh) {
@@ -15,7 +15,8 @@ object Friends
fun NavGraphBuilder.friendsScreen(
onError: (BaseError) -> Unit,
navController: NavController
navController: NavController,
onPhotoClicked: (url: String) -> Unit
) {
composable<Friends> {
val viewModel: FriendsViewModel =
@@ -23,7 +24,8 @@ fun NavGraphBuilder.friendsScreen(
FriendsRoute(
onError = onError,
viewModel = viewModel
viewModel = viewModel,
onPhotoClicked = onPhotoClicked
)
}
}
@@ -2,6 +2,7 @@ package dev.meloda.fast.friends.presentation
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -18,22 +19,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.ui.R
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.ui.R
@Composable
fun FriendItem(
modifier: Modifier = Modifier,
friend: UiFriend,
maxLines: Int
maxLines: Int,
onPhotoClicked: (url: String) -> Unit
) {
val context = LocalContext.current
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
@@ -58,7 +57,8 @@ fun FriendItem(
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
.clip(CircleShape)
.clickable { onPhotoClicked(friendAvatar) },
placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
)
}
@@ -34,7 +34,8 @@ fun FriendsList(
uiFriends: ImmutableList<UiFriend>,
listState: LazyListState,
maxLines: Int,
padding: PaddingValues
padding: PaddingValues,
onPhotoClicked: (url: String) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
@@ -58,7 +59,8 @@ fun FriendsList(
FriendItem(
friend = friend,
maxLines = maxLines
maxLines = maxLines,
onPhotoClicked = onPhotoClicked
)
Spacer(modifier = Modifier.height(16.dp))
@@ -49,6 +49,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.friends.FriendsViewModel
import dev.meloda.fast.friends.FriendsViewModelImpl
@@ -61,10 +65,6 @@ import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
@@ -72,6 +72,7 @@ import dev.meloda.fast.ui.R as UiR
@Composable
fun FriendsRoute(
onError: (BaseError) -> Unit,
onPhotoClicked: (url: String) -> Unit,
viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>()
) {
val context = LocalContext.current
@@ -102,7 +103,8 @@ fun FriendsRoute(
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh
onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked
)
}
@@ -120,7 +122,8 @@ fun FriendsScreen(
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {}
onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
@@ -307,7 +310,8 @@ fun FriendsScreen(
uiFriends = ImmutableList.copyOf(friendsToDisplay),
listState = listState,
maxLines = maxLines,
padding = padding
padding = padding,
onPhotoClicked = onPhotoClicked
)
if (friendsToDisplay.isEmpty()) {
@@ -169,7 +169,7 @@ fun MessagesHistoryScreen(
mutableStateOf(false)
}
val hazeSate = remember { HazeState() }
val hazeState = remember { HazeState() }
var animationsEnabled by remember {
mutableStateOf(
@@ -202,7 +202,7 @@ fun MessagesHistoryScreen(
.then(
if (currentTheme.enableBlur) {
Modifier.hazeChild(
state = hazeSate,
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
@@ -312,7 +312,7 @@ fun MessagesHistoryScreen(
.padding(bottom = padding.calculateBottomPadding()),
) {
MessagesList(
hazeState = hazeSate,
hazeState = hazeState,
listState = listState,
immutableMessages = ImmutableList.copyOf(screenState.messages),
isPaginating = screenState.isPaginating,
@@ -2,7 +2,7 @@ package dev.meloda.fast.messageshistory.util
import dev.meloda.fast.messageshistory.model.UiItem
fun List<UiItem>.firstMessage(): UiItem.Message = first() as UiItem.Message
fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first()
fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
indexOfFirst { it.id == messageId }
+10
View File
@@ -2,6 +2,8 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
group = "dev.meloda.fast.photoviewer"
@@ -52,4 +54,12 @@ dependencies {
implementation(libs.bundles.compose)
implementation(libs.coil.compose)
implementation(libs.haze)
implementation(libs.haze.materials)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
@@ -1,22 +1,34 @@
package dev.meloda.fast.photoviewer
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.photoviewer.model.PhotoViewArguments
import dev.meloda.fast.photoviewer.model.PhotoViewState
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.photoviewer.navigation.PhotoView
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.net.URLDecoder
interface PhotoViewViewModel {
val state: StateFlow<PhotoViewState>
fun setArguments(arguments: PhotoViewArguments)
val screenState: StateFlow<PhotoViewScreenState>
}
class PhotoViewViewModelImpl : PhotoViewViewModel, ViewModel() {
override val state = MutableStateFlow(PhotoViewState.EMPTY)
class PhotoViewViewModelImpl(
savedStateHandle: SavedStateHandle
) : PhotoViewViewModel, ViewModel() {
override fun setArguments(arguments: PhotoViewArguments) {
state.setValue { old -> old.copy(images = arguments.images) }
override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY)
init {
val arguments = PhotoView.from(savedStateHandle).arguments
screenState.setValue { old ->
old.copy(
images = arguments.images
.map { URLDecoder.decode(it, "utf-8") }
.map(UiImage::Url)
)
}
}
}
@@ -1,9 +1,11 @@
package dev.meloda.fast.photoviewer.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.common.model.UiImage
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Immutable
@Parcelize
@Serializable
data class PhotoViewArguments(
val images: List<UiImage>
)
val images: List<String>
) : Parcelable
@@ -4,12 +4,12 @@ import androidx.compose.runtime.Immutable
import dev.meloda.fast.common.model.UiImage
@Immutable
data class PhotoViewState(
data class PhotoViewScreenState(
val images: List<UiImage>
) {
companion object {
val EMPTY: PhotoViewState = PhotoViewState(
val EMPTY: PhotoViewScreenState = PhotoViewScreenState(
images = emptyList()
)
}
@@ -0,0 +1,41 @@
package dev.meloda.fast.photoviewer.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import dev.meloda.fast.common.extensions.customNavType
import dev.meloda.fast.photoviewer.model.PhotoViewArguments
import dev.meloda.fast.photoviewer.presentation.PhotoViewRoute
import kotlinx.serialization.Serializable
import java.net.URLEncoder
import kotlin.reflect.typeOf
@Serializable
data class PhotoView(val arguments: PhotoViewArguments) {
companion object {
val typeMap = mapOf(typeOf<PhotoViewArguments>() to customNavType<PhotoViewArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<PhotoView>(typeMap)
}
}
fun NavGraphBuilder.photoViewScreen(
onBack: () -> Unit
) {
composable<PhotoView>(typeMap = PhotoView.typeMap) {
PhotoViewRoute(onBack = onBack)
}
}
fun NavController.navigateToPhotoView(images: List<String>) {
this.navigate(
PhotoView(
arguments = PhotoViewArguments(
images.map { URLEncoder.encode(it, "utf-8") }
)
)
)
}
@@ -0,0 +1,249 @@
package dev.meloda.fast.photoviewer.presentation
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.conena.nanokt.android.content.pxToDp
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.photoviewer.PhotoViewViewModel
import dev.meloda.fast.photoviewer.PhotoViewViewModelImpl
import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.ui.util.getImage
import org.koin.androidx.compose.koinViewModel
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
import dev.meloda.fast.ui.R as UiR
@Composable
fun PhotoViewRoute(
onBack: () -> Unit,
viewModel: PhotoViewViewModel = koinViewModel<PhotoViewViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
PhotoViewScreen(
screenState = screenState,
onBack = onBack
)
}
@Composable
fun PhotoViewScreen(
screenState: PhotoViewScreenState = PhotoViewScreenState.EMPTY,
onBack: () -> Unit = {}
) {
val pagerState = rememberPagerState(pageCount = { screenState.images.size })
var offsetY by remember { mutableFloatStateOf(0f) }
val calculatedAlpha by remember(offsetY) {
derivedStateOf {
val absoluteOffset = abs(offsetY)
1 - if (absoluteOffset >= 1700) {
0.85f
} else absoluteOffset / 2000
}
}
Scaffold(
modifier = Modifier.graphicsLayer(alpha = calculatedAlpha),
topBar = { TopBar(onBack = onBack) },
containerColor = MaterialTheme.colorScheme.background.copy(
alpha = calculatedAlpha
)
) { padding ->
Column(modifier = Modifier.fillMaxSize()) {
Pager(
pagerState = pagerState,
state = screenState,
padding = padding,
onBack = onBack,
onVerticalDrag = { offset -> offsetY = offset },
modifier = Modifier
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopBar(
modifier: Modifier = Modifier,
onBack: () -> Unit
) {
val context = LocalContext.current
var dropdownMenuShown by remember {
mutableStateOf(false)
}
TopAppBar(
modifier = modifier,
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back button"
)
}
},
actions = {
IconButton(
onClick = { dropdownMenuShown = true }
) {
Icon(
imageVector = Icons.Rounded.MoreVert,
contentDescription = "Options"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropdownMenuShown,
onDismissRequest = { dropdownMenuShown = false },
offset = DpOffset(x = (10).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
Toast.makeText(context, "Save clicked", Toast.LENGTH_SHORT).show()
dropdownMenuShown = false
},
text = { Text(text = "Save") },
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
@Composable
fun Pager(
modifier: Modifier = Modifier,
pagerState: PagerState,
state: PhotoViewScreenState,
padding: PaddingValues,
onBack: () -> Unit,
onVerticalDrag: (offset: Float) -> Unit
) {
HorizontalPager(
state = pagerState,
modifier = modifier.fillMaxSize()
) { page ->
val model = state.images[page].getImage()
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (model is Painter) {
Image(
painter = model,
contentDescription = "Image",
modifier = Modifier.fillMaxSize()
)
} else {
var offsetY by remember { mutableFloatStateOf(0f) }
val animatedOffset by animateFloatAsState(
targetValue = offsetY,
label = "animatedOffset"
)
var useAnimatedOffset by remember {
mutableStateOf(false)
}
AsyncImage(
model = model,
contentDescription = "Image",
modifier = Modifier
.graphicsLayer {
this.translationY = if (useAnimatedOffset) {
animatedOffset
} else offsetY
}
.draggable(
state = rememberDraggableState { delta ->
useAnimatedOffset = false
offsetY += delta
onVerticalDrag(offsetY)
},
orientation = Orientation.Vertical,
onDragStopped = {
if (abs(offsetY.pxToDp()) >= 200) {
onBack()
} else {
useAnimatedOffset = true
offsetY = 0f
onVerticalDrag(0f)
}
}
)
.fillMaxSize(),
placeholder = ColorPainter(Color.DarkGray),
error = ColorPainter(Color.Red)
)
}
}
}
}
@Preview
@Composable
private fun PhotoViewScreenPreview() {
PhotoViewScreen(
screenState = PhotoViewScreenState(
images = List(200) {
UiImage.Resource(UiR.drawable.test_captcha)
}
)
)
}
@@ -1,177 +0,0 @@
package dev.meloda.fast.photoviewer.presentation
import android.graphics.drawable.ColorDrawable
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.photoviewer.PhotoViewViewModel
import dev.meloda.fast.photoviewer.model.PhotoViewState
@Composable
fun PhotoViewScreenContent(
onBackClick: () -> Unit,
viewModel: PhotoViewViewModel
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val images = state.images
val pagerState = rememberPagerState(pageCount = { images.size })
// TODO: 23/11/2023, Danil Nikolaev: заюзать штуку для цветов статус бара и навбара
Box(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xffb00b69))
) {
Spacer(
modifier = Modifier
.statusBarsPadding()
.padding(top = 56.dp)
)
Pager(
pagerState = pagerState,
state = state
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
AppBar(onBackClick = onBackClick)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBar(onBackClick: () -> Unit) {
val context = LocalContext.current
var dropdownMenuShown by remember {
mutableStateOf(false)
}
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back button"
)
}
},
actions = {
IconButton(
onClick = { dropdownMenuShown = true }
) {
Icon(
imageVector = Icons.Rounded.MoreVert,
contentDescription = "Options"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropdownMenuShown,
onDismissRequest = { dropdownMenuShown = false },
offset = DpOffset(x = (10).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
Toast.makeText(context, "Save clicked", Toast.LENGTH_SHORT).show()
dropdownMenuShown = false
},
text = { Text(text = "Save") },
)
}
}
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Pager(
pagerState: PagerState,
padding: PaddingValues = PaddingValues(0.dp),
state: PhotoViewState
) {
val images = state.images
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(padding),
key = { index -> images[index].hashCode() }
) { page ->
val model = images[page].getImage()
if (model is Painter) {
Image(
painter = model,
contentDescription = "Image",
modifier = Modifier.fillMaxSize()
)
} else {
AsyncImage(
model = model,
contentDescription = "Image",
modifier = Modifier.fillMaxSize(),
placeholder = ColorPainter(Color.DarkGray),
error = ColorPainter(Color.Red)
)
}
}
}
@Composable
fun UiImage.getImage(): Any {
return when (this) {
is UiImage.Color -> ColorDrawable(color)
is UiImage.ColorResource -> ColorDrawable(colorResource(id = resId).toArgb())
is UiImage.Resource -> painterResource(id = resId)
is UiImage.Simple -> drawable
is UiImage.Url -> url
}
}
@@ -16,6 +16,7 @@ object Profile
fun NavGraphBuilder.profileScreen(
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit,
onPhotoClicked: (url: String) -> Unit,
navController: NavController
) {
composable<Profile> {
@@ -25,6 +26,7 @@ fun NavGraphBuilder.profileScreen(
ProfileRoute(
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked,
viewModel = viewModel
)
}
@@ -1,5 +1,6 @@
package dev.meloda.fast.profile.presentation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -46,6 +47,7 @@ import dev.meloda.fast.ui.R as UiR
fun ProfileRoute(
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit,
onPhotoClicked: (url: String) -> Unit,
viewModel: ProfileViewModel = koinViewModel<ProfileViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
@@ -54,8 +56,8 @@ fun ProfileRoute(
ProfileScreen(
screenState = screenState,
baseError = baseError,
onSettingsButtonClicked = onSettingsButtonClicked
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked
)
}
@@ -66,6 +68,7 @@ fun ProfileScreen(
screenState: ProfileScreenState = ProfileScreenState.EMPTY,
baseError: BaseError? = null,
onSettingsButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}
) {
Scaffold(
topBar = {
@@ -105,7 +108,10 @@ fun ProfileScreen(
AsyncImage(
modifier = Modifier
.size(120.dp)
.clip(CircleShape),
.clip(CircleShape)
.clickable {
onPhotoClicked(screenState.avatarUrl.orEmpty())
},
model = screenState.avatarUrl,
contentDescription = null,
contentScale = ContentScale.Crop,