Skip to content

Commit

Permalink
Implement avatar and cover zoom on profile details screen (#234)
Browse files Browse the repository at this point in the history
* Implement MediaItemScreen to render given media_url
* Update advanceUntilIdleAndDelay to delay on current dispatcher
* Run PR workflow on "macos-latest"

---------

Co-authored-by: Aleksandar Ilic <[email protected]>
  • Loading branch information
markocic and AleksandarIlic committed Nov 28, 2024
1 parent 7224ff0 commit 33b0a10
Show file tree
Hide file tree
Showing 14 changed files with 377 additions and 15 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/PR-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:

jobs:
build:
runs-on: ubuntu-latest
runs-on: macos-latest
strategy:
matrix:
flavor: [Aosp, Google]
Expand Down
4 changes: 2 additions & 2 deletions app/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<ID>CyclomaticComplexMethod:NotificationsSettingsScreen.kt$@Composable private fun NotificationType.toTitle(): String</ID>
<ID>CyclomaticComplexMethod:OnboardingViewModel.kt$OnboardingViewModel$private fun observeEvents()</ID>
<ID>CyclomaticComplexMethod:PremiumOrderHistoryScreen.kt$@Composable private fun SubscriptionHeader( modifier: Modifier, state: PremiumOrderHistoryContract.UiState, onExtendSubscription: (primalName: String) -&gt; Unit, onCancelSubscription: () -&gt; Unit, )</ID>
<ID>CyclomaticComplexMethod:PrimalAppNavigation.kt$@Composable fun PrimalAppNavigation()</ID>
<ID>CyclomaticComplexMethod:PrimalAppNavigation.kt$@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun SharedTransitionScope.PrimalAppNavigation()</ID>
<ID>CyclomaticComplexMethod:ReceivePaymentScreen.kt$@Composable private fun ReceivePaymentViewer( paddingValues: PaddingValues, state: UiState, onBuyPremium: () -&gt; Unit, onCopyClick: () -&gt; Unit, onEditClick: () -&gt; Unit, )</ID>
<ID>CyclomaticComplexMethod:TransactionEditor.kt$@Composable private fun TransactionHeaderColumn( modifier: Modifier, uiMode: UiDensityMode, state: CreateTransactionContract.UiState, keyboardVisible: Boolean, onAmountClick: () -&gt; Unit, )</ID>
<ID>CyclomaticComplexMethod:WalletDashboardScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun WalletDashboardScreen( state: WalletDashboardContract.UiState, onPrimaryDestinationChanged: (PrimalTopLevelDestination) -&gt; Unit, onDrawerDestinationClick: (DrawerScreenDestination) -&gt; Unit, onDrawerQrCodeClick: () -&gt; Unit, onWalletActivateClick: () -&gt; Unit, onProfileClick: (String) -&gt; Unit, onTransactionClick: (String) -&gt; Unit, onSendClick: () -&gt; Unit, onScanClick: () -&gt; Unit, onReceiveClick: () -&gt; Unit, eventPublisher: (UiEvent) -&gt; Unit, )</ID>
Expand Down Expand Up @@ -74,7 +74,7 @@
<ID>LongMethod:PremiumOrderHistoryScreen.kt$@Composable private fun SubscriptionHeader( modifier: Modifier, state: PremiumOrderHistoryContract.UiState, onExtendSubscription: (primalName: String) -&gt; Unit, onCancelSubscription: () -&gt; Unit, )</ID>
<ID>LongMethod:PremiumOrderHistoryScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable private fun PremiumOrderHistoryScreen( state: PremiumOrderHistoryContract.UiState, eventPublisher: (PremiumOrderHistoryContract.UiEvent) -&gt; Unit, onExtendSubscription: (primalName: String) -&gt; Unit, onClose: () -&gt; Unit, )</ID>
<ID>LongMethod:PremiumPurchaseStage.kt$@ExperimentalMaterial3Api @Composable fun PremiumPurchaseStage( state: PremiumBuyingContract.UiState, onBack: () -&gt; Unit, onLearnMoreClick: () -&gt; Unit, eventPublisher: (PremiumBuyingContract.UiEvent) -&gt; Unit, )</ID>
<ID>LongMethod:PrimalAppNavigation.kt$@Composable fun PrimalAppNavigation()</ID>
<ID>LongMethod:PrimalAppNavigation.kt$@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun SharedTransitionScope.PrimalAppNavigation()</ID>
<ID>LongMethod:PrimalDrawer.kt$@Composable private fun DrawerHeader( userAccount: UserAccount?, customBadge: Boolean, avatarGlow: Boolean, legendaryStyle: LegendaryStyle?, onQrCodeClick: () -&gt; Unit, )</ID>
<ID>LongMethod:PrimalDrawerScaffold.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun PrimalDrawerScaffold( modifier: Modifier = Modifier, drawerState: DrawerState, activeDestination: PrimalTopLevelDestination, onPrimaryDestinationChanged: (PrimalTopLevelDestination) -&gt; Unit, onDrawerDestinationClick: (DrawerScreenDestination) -&gt; Unit, onDrawerQrCodeClick: () -&gt; Unit, badges: Badges = Badges(), onActiveDestinationClick: () -&gt; Unit = {}, topAppBarState: TopAppBarState = remember { TopAppBarState( initialHeightOffsetLimit = -Float.MAX_VALUE, initialHeightOffset = 0f, initialContentOffset = 0f, ) }, topAppBar: @Composable (TopAppBarScrollBehavior?) -&gt; Unit = {}, content: @Composable (PaddingValues) -&gt; Unit = {}, floatingNewDataHost: @Composable () -&gt; Unit = {}, floatingActionButton: @Composable () -&gt; Unit = {}, snackbarHost: @Composable () -&gt; Unit = {}, focusModeEnabled: Boolean = true, )</ID>
<ID>LongMethod:PrimalNavigationBar.kt$@Composable fun PrimalNavigationBarLightningBolt( modifier: Modifier = Modifier, activeDestination: PrimalTopLevelDestination, onTopLevelDestinationChanged: (PrimalTopLevelDestination) -&gt; Unit, onActiveDestinationClick: (() -&gt; Unit)? = null, badges: Badges = Badges(), )</ID>
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/kotlin/net/primal/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.RippleConfiguration
Expand Down Expand Up @@ -41,7 +43,7 @@ class MainActivity : ComponentActivity() {

lateinit var primalTheme: PrimalTheme

@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
Expand Down Expand Up @@ -72,7 +74,10 @@ class MainActivity : ComponentActivity() {
LocalContentDisplaySettings provides contentDisplaySettings.value,
) {
ApplyEdgeToEdge()
PrimalAppNavigation()

SharedTransitionLayout {
PrimalAppNavigation()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ private fun MediaGalleryContent(
}

@Composable
private fun GalleryDropdownMenu(onSaveClick: () -> Unit) {
fun GalleryDropdownMenu(onSaveClick: () -> Unit) {
var menuVisible by remember { mutableStateOf(false) }

AppBarIcon(
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/kotlin/net/primal/android/media/MediaItemContract.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package net.primal.android.media

interface MediaItemContract {
data class UiState(
val mediaUrl: String,
val error: MediaItemError? = null,
) {
sealed class MediaItemError {
data class FailedToSaveMedia(val cause: Throwable) : MediaItemError()
}
}

sealed class UiEvent {
data object SaveMedia : UiEvent()
data object DismissError : UiEvent()
}

sealed class SideEffect {
data object MediaSaved : SideEffect()
}
}
184 changes: 184 additions & 0 deletions app/src/main/kotlin/net/primal/android/media/MediaItemScreen.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package net.primal.android.media

import android.widget.Toast
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import coil.imageLoader
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.SuccessResult
import kotlinx.coroutines.launch
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
import me.saket.telephoto.zoomable.rememberZoomableState
import net.primal.android.R
import net.primal.android.attachments.gallery.GalleryDropdownMenu
import net.primal.android.core.compose.AppBarIcon
import net.primal.android.core.compose.SnackbarErrorHandler
import net.primal.android.core.compose.icons.PrimalIcons
import net.primal.android.core.compose.icons.primaliconpack.ArrowBack
import net.primal.android.theme.AppTheme

@OptIn(ExperimentalMaterial3Api::class)
@ExperimentalSharedTransitionApi
@Composable
fun MediaItemScreen(
viewModel: MediaItemViewModel,
onClose: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
) {
val uiState = viewModel.state.collectAsState()

val uiScope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(viewModel) {
viewModel.effects.collect {
when (it) {
MediaItemContract.SideEffect.MediaSaved -> uiScope.launch {
Toast.makeText(context, context.getString(R.string.media_item_saved), Toast.LENGTH_SHORT).show()
}
}
}
}

MediaItemScreen(
state = uiState.value,
onClose = onClose,
eventPublisher = viewModel::setEvent,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}

@ExperimentalMaterial3Api
@ExperimentalSharedTransitionApi
@Composable
private fun MediaItemScreen(
state: MediaItemContract.UiState,
eventPublisher: (MediaItemContract.UiEvent) -> Unit,
onClose: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
) {
val snackbarHostState = remember { SnackbarHostState() }
SnackbarErrorHandler(
error = state.error,
snackbarHostState = snackbarHostState,
errorMessageResolver = {
when (it) {
is MediaItemContract.UiState.MediaItemError.FailedToSaveMedia ->
stringResource(id = R.string.media_item_error_on_save_message)
}
},
actionLabel = stringResource(id = R.string.media_item_error_on_save_try_again),
onErrorDismiss = { eventPublisher(MediaItemContract.UiEvent.DismissError) },
onActionPerformed = { eventPublisher(MediaItemContract.UiEvent.SaveMedia) },
)
val containerColor = AppTheme.colorScheme.surface.copy(alpha = 0.21f)

Scaffold(
contentColor = AppTheme.colorScheme.background,
topBar = {
CenterAlignedTopAppBar(
title = {},
navigationIcon = {
AppBarIcon(
icon = PrimalIcons.ArrowBack,
onClick = onClose,
appBarIconContentDescription = stringResource(id = R.string.accessibility_back_button),
)
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = containerColor,
scrolledContainerColor = containerColor,
),
actions = {
GalleryDropdownMenu(
onSaveClick = { eventPublisher(MediaItemContract.UiEvent.SaveMedia) },
)
},
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
) {
with(sharedTransitionScope) {
MediaItemContent(
modifier = Modifier.padding(it),
mediaUrl = state.mediaUrl,
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
}

@ExperimentalSharedTransitionApi
@Composable
fun SharedTransitionScope.MediaItemContent(
modifier: Modifier = Modifier,
mediaUrl: String,
animatedVisibilityScope: AnimatedVisibilityScope,
) {
val zoomSpec = ZoomSpec(maxZoomFactor = 15f)

var error by remember { mutableStateOf<ErrorResult?>(null) }
val loadingImageListener = remember {
object : ImageRequest.Listener {
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
error = null
}

override fun onError(request: ImageRequest, result: ErrorResult) {
error = result
}
}
}
val imageLoader = LocalContext.current.imageLoader

Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
ZoomableAsyncImage(
modifier = Modifier
.sharedElement(
state = rememberSharedContentState(key = "mediaItem"),
animatedVisibilityScope = animatedVisibilityScope,
)
.fillMaxSize(),
state = rememberZoomableImageState(rememberZoomableState(zoomSpec = zoomSpec)),
imageLoader = imageLoader,
model = ImageRequest.Builder(LocalContext.current)
.data(mediaUrl)
.listener(loadingImageListener)
.crossfade(durationMillis = 300)
.build(),
contentDescription = null,
)
}
}
76 changes: 76 additions & 0 deletions app/src/main/kotlin/net/primal/android/media/MediaItemViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package net.primal.android.media

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.primal.android.core.coroutines.CoroutineDispatcherProvider
import net.primal.android.core.files.MediaDownloader
import net.primal.android.core.files.error.UnableToSaveContent
import net.primal.android.core.files.error.UnsuccessfulFileDownload
import net.primal.android.media.MediaItemContract.SideEffect
import net.primal.android.media.MediaItemContract.UiEvent
import net.primal.android.media.MediaItemContract.UiState
import net.primal.android.navigation.asUrlDecodedNonNullable
import net.primal.android.navigation.mediaUrlOrThrow
import timber.log.Timber

@HiltViewModel
class MediaItemViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val mediaDownloader: MediaDownloader,
) : ViewModel() {

private val mediaUrl = savedStateHandle.mediaUrlOrThrow.asUrlDecodedNonNullable()

private val _state = MutableStateFlow(UiState(mediaUrl = mediaUrl))
val state = _state.asStateFlow()
private fun setState(reducer: UiState.() -> UiState) = _state.getAndUpdate { it.reducer() }

private val events: MutableSharedFlow<UiEvent> = MutableSharedFlow()
fun setEvent(event: UiEvent) = viewModelScope.launch { events.emit(event) }

private val _effects = Channel<SideEffect>()
val effects = _effects.receiveAsFlow()
private fun setEffect(effect: SideEffect) = viewModelScope.launch { _effects.send(effect) }

init {
observeEvents()
}

private fun observeEvents() =
viewModelScope.launch {
events.collect {
when (it) {
UiEvent.DismissError -> setState { copy(error = null) }
UiEvent.SaveMedia -> saveMedia()
}
}
}

private fun saveMedia() =
viewModelScope.launch {
withContext(dispatcherProvider.io()) {
try {
mediaDownloader.downloadToMediaGallery(url = state.value.mediaUrl)
setEffect(SideEffect.MediaSaved)
} catch (error: UnsuccessfulFileDownload) {
Timber.w(error)
setState { copy(error = UiState.MediaItemError.FailedToSaveMedia(error)) }
} catch (error: UnableToSaveContent) {
Timber.w(error)
setState { copy(error = UiState.MediaItemError.FailedToSaveMedia(error)) }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ inline val SavedStateHandle.nwcUrl: String? get() = get(NWC_URL)

const val MEDIA_URL = "mediaUrl"
inline val SavedStateHandle.mediaUrl: String? get() = get(MEDIA_URL)
inline val SavedStateHandle.mediaUrlOrThrow: String
get() = get(MEDIA_URL) ?: throw IllegalArgumentException("Missing required $MEDIA_URL argument.")

const val MEDIA_POSITION_MS = "mediaPositionMs"
inline val SavedStateHandle.mediaPositionMs: Long get() = get(MEDIA_POSITION_MS) ?: 0L
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ fun NavOptionsBuilder.clearBackStack() = popUpTo(id = 0)

fun String.asUrlEncoded(): String = URLEncoder.encode(this, CharEncoding.UTF_8)

fun String.asUrlDecodedNonNullable(): String = URLDecoder.decode(this, CharEncoding.UTF_8)

fun String?.asUrlDecoded() =
when (this) {
null -> null
Expand Down
Loading

0 comments on commit 33b0a10

Please sign in to comment.