MediaViewer: first step

This commit is contained in:
ganfra 2023-05-02 19:21:39 +02:00
parent 4b60b14550
commit ed10fc6651
11 changed files with 397 additions and 11 deletions

View file

@ -18,6 +18,7 @@ plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("kotlin-parcelize")
} }
android { android {

View file

@ -18,12 +18,10 @@ package io.element.android.features.messages.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@ -33,6 +31,6 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
buildContext: BuildContext, buildContext: BuildContext,
callback: MessagesEntryPoint.Callback callback: MessagesEntryPoint.Callback
): Node { ): Node {
return parentNode.createNode<MessagesNode>(buildContext, listOf(callback)) return parentNode.createNode<MessagesFlowNode>(buildContext, listOf(callback))
} }
} }

View file

@ -0,0 +1,107 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import kotlinx.android.parcel.Parcelize
@ContributesNode(RoomScope::class)
class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object Messages : NavTarget
@Parcelize
data class MediaViewer(val mediaContent: MediaContentUiModel) : NavTarget
}
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Messages -> {
val callback = object : MessagesNode.Callback {
override fun onRoomDetailsClicked() {
callback?.onRoomDetailsClicked()
}
override fun onEventClicked(event: TimelineItem.Event) {
processEventClicked(event)
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}
is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs(navTarget.mediaContent)
createNode<MediaViewerNode>(buildContext, listOf(inputs))
}
}
}
private fun processEventClicked(event: TimelineItem.Event) {
when (event.content) {
is TimelineItemImageContent -> {
val mediaContent = MediaContentUiModel.Image(
body = event.content.body,
url = event.content.mediaRequestData.url,
blurhash = event.content.blurhash
)
backstack.push(NavTarget.MediaViewer(mediaContent))
}
else -> Unit
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
)
}
}

View file

@ -25,9 +25,8 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class MessagesNode @AssistedInject constructor( class MessagesNode @AssistedInject constructor(
@ -36,12 +35,21 @@ class MessagesNode @AssistedInject constructor(
private val presenter: MessagesPresenter, private val presenter: MessagesPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull() private val callback = plugins<Callback>().firstOrNull()
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onEventClicked(event: TimelineItem.Event)
}
private fun onRoomDetailsClicked() { private fun onRoomDetailsClicked() {
callback?.onRoomDetailsClicked() callback?.onRoomDetailsClicked()
} }
private fun onEventClicked(event: TimelineItem.Event) {
callback?.onEventClicked(event)
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -49,7 +57,8 @@ class MessagesNode @AssistedInject constructor(
state = state, state = state,
onBackPressed = this::navigateUp, onBackPressed = this::navigateUp,
onRoomDetailsClicked = this::onRoomDetailsClicked, onRoomDetailsClicked = this::onRoomDetailsClicked,
modifier = modifier onEventClicked = this::onEventClicked,
modifier = modifier,
) )
} }
} }

View file

@ -60,6 +60,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.textcomposer.MessageComposerView import io.element.android.features.messages.impl.textcomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -70,16 +71,16 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@Composable @Composable
fun MessagesView( fun MessagesView(
state: MessagesState, state: MessagesState,
onBackPressed: () -> Unit,
onRoomDetailsClicked: () -> Unit,
onEventClicked: (event: TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onRoomDetailsClicked: () -> Unit = {},
) { ) {
LogCompositions(tag = "MessagesScreen", msg = "Root") LogCompositions(tag = "MessagesScreen", msg = "Root")
val itemActionsBottomSheetState = rememberModalBottomSheetState( val itemActionsBottomSheetState = rememberModalBottomSheetState(
@ -93,6 +94,7 @@ fun MessagesView(
fun onMessageClicked(event: TimelineItem.Event) { fun onMessageClicked(event: TimelineItem.Event) {
Timber.v("OnMessageClicked= ${event.id}") Timber.v("OnMessageClicked= ${event.id}")
onEventClicked(event)
} }
fun onMessageLongClicked(event: TimelineItem.Event) { fun onMessageLongClicked(event: TimelineItem.Event) {
@ -228,5 +230,5 @@ internal fun MessagesViewDarkPreview(@PreviewParameter(MessagesStateProvider::cl
@Composable @Composable
private fun ContentToPreview(state: MessagesState) { private fun ContentToPreview(state: MessagesState) {
MessagesView(state) MessagesView(state, {}, {}, {})
} }

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.viewer
// TODO Add your events or remove the file completely if no events
sealed interface MediaViewerEvents {
object MyEvent : MediaViewerEvents
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.viewer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val mediaContent: MediaContentUiModel) : NodeInputs
private val inputs: Inputs = inputs()
@Composable
override fun View(modifier: Modifier) {
MediaViewerView(
state = MediaViewerState(inputs.mediaContent),
modifier = modifier
)
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.viewer
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
data class MediaViewerState(
val mediaContent: MediaContentUiModel
)

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.viewer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
aMediaViewerState(),
)
}
fun aMediaViewerState() = MediaViewerState(
mediaContent = aMediaImage(),
)
private fun aMediaImage() = MediaContentUiModel.Image(
body = "a body",
url = "",
blurhash = null,
)

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.viewer
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@Composable
fun MediaViewerView(
state: MediaViewerState,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
when (state.mediaContent) {
is MediaContentUiModel.Image -> MediaImageViewer(state.mediaContent)
is MediaContentUiModel.Video -> MediaVideoViewer(state.mediaContent)
}
}
}
@Composable
private fun MediaImageViewer(
image: MediaContentUiModel.Image,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
BlurHashAsyncImage(
blurHash = image.blurhash,
model = image.mediaRequestData,
contentScale = ContentScale.Crop,
)
}
}
@Composable
private fun MediaVideoViewer(
video: MediaContentUiModel.Video,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
}
}
@Preview
@Composable
fun MediaViewerViewLightPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: MediaViewerState) {
MediaViewerView(
state = state,
)
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.viewer.model
import android.os.Parcelable
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.android.parcel.Parcelize
sealed interface MediaContentUiModel : Parcelable {
@Parcelize
data class Image(
val body: String,
val url: String,
val blurhash: String?,
) : MediaContentUiModel {
val mediaRequestData = MediaRequestData(
url = url, kind = MediaRequestData.Kind.Content
)
}
@Parcelize
data class Video(
val body: String,
) : MediaContentUiModel
}