Merge branch 'develop' into feature/fga/media_viewer_actions
24
.github/renovate.json
vendored
|
|
@ -1,18 +1,28 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema" : "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends" : [
|
||||||
"config:base"
|
"config:base"
|
||||||
],
|
],
|
||||||
"labels": ["dependencies"],
|
"labels" : [
|
||||||
"ignoreDeps": ["string:app_name"],
|
"dependencies"
|
||||||
"packageRules": [
|
],
|
||||||
|
"ignoreDeps" : [
|
||||||
|
"string:app_name"
|
||||||
|
],
|
||||||
|
"packageRules" : [
|
||||||
{
|
{
|
||||||
"matchPackagePatterns": [
|
"matchPackagePatterns" : [
|
||||||
"^org.jetbrains.kotlin",
|
"^org.jetbrains.kotlin",
|
||||||
"^com.google.devtools.ksp",
|
"^com.google.devtools.ksp",
|
||||||
"^androidx.compose.compiler"
|
"^androidx.compose.compiler"
|
||||||
],
|
],
|
||||||
"groupName": "kotlin"
|
"groupName" : "kotlin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames" : [
|
||||||
|
"org.jetbrains.kotlinx.kover"
|
||||||
|
],
|
||||||
|
"enabled" : false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
.github/workflows/recordScreenshots.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
name: Record screenshots
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Enrich gradle.properties for CI/CD
|
||||||
|
env:
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
record:
|
||||||
|
name: Record screenshots on branch ${{ inputs.param_branch }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: ⏬ Checkout with LFS
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
lfs: 'true'
|
||||||
|
- name: ☕️ Use JDK 17
|
||||||
|
uses: actions/setup-java@v3
|
||||||
|
with:
|
||||||
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
|
java-version: '17'
|
||||||
|
- name: Record screenshots
|
||||||
|
run: "./.github/workflows/scripts/recordScreenshots.sh"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
|
||||||
55
.github/workflows/scripts/recordScreenshots.sh
vendored
Executable file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
|
||||||
|
if [[ -z ${GITHUB_TOKEN} ]]; then
|
||||||
|
echo "Missing GITHUB_TOKEN variable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${GITHUB_REPOSITORY} ]]; then
|
||||||
|
echo "Missing GITHUB_REPOSITORY variable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${GITHUB_REF_NAME} ]]; then
|
||||||
|
echo "Missing GITHUB_REF_NAME variable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "ElementBot"
|
||||||
|
git config user.email "benoitm+elementbot@element.io"
|
||||||
|
|
||||||
|
echo "Git status"
|
||||||
|
git status
|
||||||
|
|
||||||
|
echo "Fetching..."
|
||||||
|
git fetch --all
|
||||||
|
|
||||||
|
echo "Checkout origin/$GITHUB_REF_NAME"
|
||||||
|
git checkout "origin/$GITHUB_REF_NAME"
|
||||||
|
|
||||||
|
echo "Record screenshots"
|
||||||
|
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
||||||
|
|
||||||
|
echo "Committing changes"
|
||||||
|
git add -A
|
||||||
|
git commit -m "Update screenshots"
|
||||||
|
|
||||||
|
echo "Pushing changes"
|
||||||
|
git push "https://$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git"
|
||||||
|
echo "Done!"
|
||||||
2
.idea/dictionaries/shared.xml
generated
|
|
@ -2,6 +2,8 @@
|
||||||
<dictionary name="shared">
|
<dictionary name="shared">
|
||||||
<words>
|
<words>
|
||||||
<w>backstack</w>
|
<w>backstack</w>
|
||||||
|
<w>kover</w>
|
||||||
|
<w>onboarding</w>
|
||||||
<w>textfields</w>
|
<w>textfields</w>
|
||||||
</words>
|
</words>
|
||||||
</dictionary>
|
</dictionary>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
appId: ${APP_ID}
|
appId: ${APP_ID}
|
||||||
---
|
---
|
||||||
- tapOn: "Get started"
|
- tapOn: "Sign in manually"
|
||||||
- runFlow: ../assertions/assertLoginDisplayed.yaml
|
- runFlow: ../assertions/assertLoginDisplayed.yaml
|
||||||
- takeScreenshot: build/maestro/100-SignIn
|
- takeScreenshot: build/maestro/100-SignIn
|
||||||
- runFlow: changeServer.yaml
|
- runFlow: changeServer.yaml
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
appId: ${APP_ID}
|
appId: ${APP_ID}
|
||||||
---
|
---
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Own your conversations."
|
visible: "Communicate and collaborate securely"
|
||||||
timeout: 10_000
|
timeout: 10_000
|
||||||
|
|
|
||||||
|
|
@ -142,15 +142,6 @@ android {
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waiting for https://github.com/google/ksp/issues/37
|
|
||||||
applicationVariants.all {
|
|
||||||
kotlin.sourceSets {
|
|
||||||
getByName(name) {
|
|
||||||
kotlin.srcDir("build/generated/ksp/$name/kotlin")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,10 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
tryToRestoreLatestSession(
|
tryToRestoreLatestSession(
|
||||||
onSuccess = { switchToLoggedInFlow(it) },
|
onSuccess = { switchToLoggedInFlow(it) },
|
||||||
onFailure = { switchToLogoutFlow() }
|
onFailure = { switchToNotLoggedInFlow() }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
switchToLogoutFlow()
|
switchToNotLoggedInFlow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
|
|
@ -106,7 +106,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
|
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun switchToLogoutFlow() {
|
private fun switchToNotLoggedInFlow() {
|
||||||
matrixClientsHolder.removeAll()
|
matrixClientsHolder.removeAll()
|
||||||
backstack.safeRoot(NavTarget.NotLoggedInFlow)
|
backstack.safeRoot(NavTarget.NotLoggedInFlow)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,8 @@ koverMerged {
|
||||||
includes += "*Presenter"
|
includes += "*Presenter"
|
||||||
excludes += "*Fake*Presenter"
|
excludes += "*Fake*Presenter"
|
||||||
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
|
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
|
||||||
|
// Too small presenter, cannot reach the threshold.
|
||||||
|
excludes += "io.element.android.features.onboarding.impl.OnBoardingPresenter"
|
||||||
}
|
}
|
||||||
bound {
|
bound {
|
||||||
minValue = 90
|
minValue = 90
|
||||||
|
|
|
||||||
1
changelog.d/483.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Redesign the timeline item context menu using M3 bottom sheet
|
||||||
|
|
@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -32,6 +33,7 @@ import io.element.android.libraries.ui.strings.R
|
||||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchUserBar(
|
fun SearchUserBar(
|
||||||
query: String,
|
query: String,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
||||||
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
||||||
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.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
|
|
@ -134,6 +135,16 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
backstack.push(navTarget)
|
backstack.push(navTarget)
|
||||||
}
|
}
|
||||||
|
is TimelineItemFileContent -> {
|
||||||
|
val mediaSource = event.content.fileSource
|
||||||
|
val navTarget = NavTarget.MediaViewer(
|
||||||
|
title = event.content.body,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
thumbnailSource = event.content.thumbnailSource,
|
||||||
|
mimeType = event.content.mimeType,
|
||||||
|
)
|
||||||
|
backstack.push(navTarget)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,8 @@ class MessagesPresenter @Inject constructor(
|
||||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||||
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
||||||
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
||||||
|
TimelineItemAction.Developer -> notImplementedYet()
|
||||||
|
TimelineItemAction.ReportContent -> notImplementedYet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl
|
package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
|
@ -32,27 +31,22 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
|
||||||
import androidx.compose.material.ListItem
|
|
||||||
import androidx.compose.material.ModalBottomSheetValue
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
|
||||||
import androidx.compose.material.icons.filled.Collections
|
|
||||||
import androidx.compose.material.icons.filled.PhotoCamera
|
|
||||||
import androidx.compose.material.icons.filled.Videocam
|
|
||||||
import androidx.compose.material.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -66,9 +60,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
|
||||||
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.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||||
|
|
@ -80,7 +72,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
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
|
||||||
|
|
@ -91,7 +82,7 @@ import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import io.element.android.libraries.ui.strings.R as StringsR
|
import io.element.android.libraries.ui.strings.R as StringsR
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesView(
|
fun MessagesView(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
|
|
@ -103,26 +94,11 @@ fun MessagesView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
|
||||||
initialValue = ModalBottomSheetValue.Hidden,
|
|
||||||
)
|
|
||||||
val composerState = state.composerState
|
|
||||||
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.showAttachmentSourcePicker) {
|
|
||||||
ModalBottomSheetValue.Expanded
|
|
||||||
} else {
|
|
||||||
ModalBottomSheetValue.Hidden
|
|
||||||
}
|
|
||||||
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
||||||
|
|
||||||
BackHandler(enabled = bottomSheetState.isVisible) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
bottomSheetState.hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
|
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
|
||||||
if (snackbarMessageText != null) {
|
if (snackbarMessageText != null) {
|
||||||
|
|
@ -150,78 +126,57 @@ fun MessagesView(
|
||||||
Timber.v("OnMessageLongClicked= ${event.id}")
|
Timber.v("OnMessageLongClicked= ${event.id}")
|
||||||
localView.hideKeyboard()
|
localView.hideKeyboard()
|
||||||
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
|
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
|
||||||
coroutineScope.launch {
|
isMessageActionsBottomSheetVisible = true
|
||||||
itemActionsBottomSheetState.show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
||||||
|
isMessageActionsBottomSheetVisible = false
|
||||||
state.eventSink(MessagesEvents.HandleAction(action, event))
|
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(composerState.showAttachmentSourcePicker) {
|
fun onDismissActionListBottomSheet() {
|
||||||
if (composerState.showAttachmentSourcePicker) {
|
isMessageActionsBottomSheetVisible = false
|
||||||
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
|
||||||
localView.hideKeyboard()
|
|
||||||
bottomSheetState.show()
|
|
||||||
} else {
|
|
||||||
bottomSheetState.hide()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
|
|
||||||
LaunchedEffect(bottomSheetState.isVisible) {
|
|
||||||
if (!bottomSheetState.isVisible) {
|
|
||||||
composerState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ModalBottomSheetLayout(
|
|
||||||
sheetState = bottomSheetState,
|
|
||||||
displayHandle = true,
|
|
||||||
sheetContent = {
|
|
||||||
AttachmentSourcePickerMenu(
|
|
||||||
eventSink = composerState.eventSink
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
|
||||||
topBar = {
|
|
||||||
Column {
|
|
||||||
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
|
||||||
MessagesViewTopBar(
|
|
||||||
roomTitle = state.roomName,
|
|
||||||
roomAvatar = state.roomAvatar,
|
|
||||||
onBackPressed = onBackPressed,
|
|
||||||
onRoomDetailsClicked = onRoomDetailsClicked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
content = { padding ->
|
|
||||||
MessagesViewContent(
|
|
||||||
state = state,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(padding)
|
|
||||||
.consumeWindowInsets(padding),
|
|
||||||
onMessageClicked = ::onMessageClicked,
|
|
||||||
onMessageLongClicked = ::onMessageLongClicked,
|
|
||||||
onUserDataClicked = onUserDataClicked,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = {
|
|
||||||
SnackbarHost(
|
|
||||||
snackbarHostState,
|
|
||||||
modifier = Modifier.navigationBarsPadding()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
ActionListView(
|
Scaffold(
|
||||||
state = state.actionListState,
|
modifier = modifier,
|
||||||
modalBottomSheetState = itemActionsBottomSheetState,
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
onActionSelected = ::onActionSelected
|
topBar = {
|
||||||
)
|
Column {
|
||||||
}
|
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
||||||
|
MessagesViewTopBar(
|
||||||
|
roomTitle = state.roomName,
|
||||||
|
roomAvatar = state.roomAvatar,
|
||||||
|
onBackPressed = onBackPressed,
|
||||||
|
onRoomDetailsClicked = onRoomDetailsClicked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = { padding ->
|
||||||
|
MessagesViewContent(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.consumeWindowInsets(padding),
|
||||||
|
onMessageClicked = ::onMessageClicked,
|
||||||
|
onMessageLongClicked = ::onMessageLongClicked,
|
||||||
|
onUserDataClicked = onUserDataClicked,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(
|
||||||
|
snackbarHostState,
|
||||||
|
modifier = Modifier.navigationBarsPadding()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ActionListView(
|
||||||
|
state = state.actionListState,
|
||||||
|
isVisible = isMessageActionsBottomSheetVisible,
|
||||||
|
onDismiss = ::onDismissActionListBottomSheet,
|
||||||
|
onActionSelected = ::onActionSelected
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -312,36 +267,6 @@ fun MessagesViewTopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
|
||||||
@Composable
|
|
||||||
internal fun AttachmentSourcePickerMenu(
|
|
||||||
eventSink: (MessageComposerEvents) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(modifier) {
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
|
||||||
icon = { Icon(Icons.Default.Collections, null) },
|
|
||||||
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
|
||||||
)
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
|
||||||
icon = { Icon(Icons.Default.AttachFile, null) },
|
|
||||||
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
|
||||||
)
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
|
||||||
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
|
||||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
|
||||||
)
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
|
||||||
icon = { Icon(Icons.Default.Videocam, null) },
|
|
||||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =
|
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,15 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
|
class ActionListPresenter @Inject constructor(
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) : Presenter<ActionListState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ActionListState {
|
override fun present(): ActionListState {
|
||||||
|
|
@ -60,21 +63,30 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
|
||||||
when (timelineItem.content) {
|
when (timelineItem.content) {
|
||||||
is TimelineItemRedactedContent,
|
is TimelineItemRedactedContent,
|
||||||
is TimelineItemStateContent -> {
|
is TimelineItemStateContent -> {
|
||||||
// TODO Add Share action (also) here, and developer options
|
buildList {
|
||||||
emptyList()
|
add(TimelineItemAction.Copy)
|
||||||
}
|
if (buildMeta.isDebuggable) {
|
||||||
else -> {
|
add(TimelineItemAction.Developer)
|
||||||
mutableListOf(
|
|
||||||
TimelineItemAction.Reply,
|
|
||||||
TimelineItemAction.Forward,
|
|
||||||
TimelineItemAction.Copy,
|
|
||||||
).also {
|
|
||||||
if (timelineItem.isMine) {
|
|
||||||
it.add(TimelineItemAction.Edit)
|
|
||||||
it.add(TimelineItemAction.Redact)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else -> buildList<TimelineItemAction> {
|
||||||
|
add(TimelineItemAction.Reply)
|
||||||
|
add(TimelineItemAction.Forward)
|
||||||
|
if (timelineItem.isMine) {
|
||||||
|
add(TimelineItemAction.Edit)
|
||||||
|
}
|
||||||
|
add(TimelineItemAction.Copy)
|
||||||
|
if (buildMeta.isDebuggable) {
|
||||||
|
add(TimelineItemAction.Developer)
|
||||||
|
}
|
||||||
|
if (!timelineItem.isMine) {
|
||||||
|
add(TimelineItemAction.ReportContent)
|
||||||
|
}
|
||||||
|
if (timelineItem.isMine) {
|
||||||
|
add(TimelineItemAction.Redact)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
|
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,52 +16,81 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.actionlist
|
package io.element.android.features.messages.impl.actionlist
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.ListItem
|
import androidx.compose.material.ListItem
|
||||||
import androidx.compose.material.ModalBottomSheetState
|
|
||||||
import androidx.compose.material.ModalBottomSheetValue
|
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AddReaction
|
||||||
|
import androidx.compose.material.icons.outlined.Attachment
|
||||||
|
import androidx.compose.material.icons.outlined.VideoCameraBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
|
||||||
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.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Divider
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
|
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||||
import kotlinx.coroutines.flow.filter
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
import kotlinx.coroutines.launch
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ActionListView(
|
fun ActionListView(
|
||||||
state: ActionListState,
|
state: ActionListState,
|
||||||
modalBottomSheetState: ModalBottomSheetState,
|
isVisible: Boolean,
|
||||||
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
|
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
LaunchedEffect(isVisible) {
|
||||||
LaunchedEffect(modalBottomSheetState) {
|
if (!isVisible) {
|
||||||
snapshotFlow { modalBottomSheetState.currentValue }
|
state.eventSink(ActionListEvents.Clear)
|
||||||
.filter { it == ModalBottomSheetValue.Hidden }
|
}
|
||||||
.collect {
|
|
||||||
state.eventSink(ActionListEvents.Clear)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onItemActionClicked(
|
fun onItemActionClicked(
|
||||||
|
|
@ -69,24 +98,22 @@ fun ActionListView(
|
||||||
targetItem: TimelineItem.Event
|
targetItem: TimelineItem.Event
|
||||||
) {
|
) {
|
||||||
onActionSelected(itemAction, targetItem)
|
onActionSelected(itemAction, targetItem)
|
||||||
coroutineScope.launch {
|
|
||||||
modalBottomSheetState.hide()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ModalBottomSheetLayout(
|
if (isVisible) {
|
||||||
modifier = modifier,
|
ModalBottomSheet(
|
||||||
sheetState = modalBottomSheetState,
|
onDismissRequest = onDismiss
|
||||||
sheetContent = {
|
) {
|
||||||
SheetContent(
|
SheetContent(
|
||||||
state = state,
|
state = state,
|
||||||
onActionClicked = ::onItemActionClicked,
|
onActionClicked = ::onItemActionClicked,
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.navigationBarsPadding()
|
.padding(bottom = 32.dp)
|
||||||
.imePadding()
|
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||||
|
// .imePadding()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
|
|
@ -108,6 +135,19 @@ private fun SheetContent(
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
|
Column {
|
||||||
|
MessageSummary(event = target.event, modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp))
|
||||||
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
EmojiReactionsRow(Modifier.fillMaxWidth())
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
items(
|
items(
|
||||||
items = actions,
|
items = actions,
|
||||||
) { action ->
|
) { action ->
|
||||||
|
|
@ -135,6 +175,141 @@ private fun SheetContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
|
||||||
|
val content: @Composable () -> Unit
|
||||||
|
var icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.SMALL)) }
|
||||||
|
val contentStyle = ElementTextStyles.Regular.bodyMD.copy(color = MaterialTheme.colorScheme.secondary)
|
||||||
|
val imageModifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(9.dp))
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContentForBody(body: String) {
|
||||||
|
Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (event.content) {
|
||||||
|
is TimelineItemTextBasedContent -> content = { ContentForBody(event.content.body) }
|
||||||
|
is TimelineItemStateContent -> content = { ContentForBody(event.content.body) }
|
||||||
|
is TimelineItemProfileChangeContent -> content = { ContentForBody(event.content.body) }
|
||||||
|
is TimelineItemEncryptedContent -> content = { ContentForBody(stringResource(StringR.string.common_unable_to_decrypt)) }
|
||||||
|
is TimelineItemRedactedContent -> content = { ContentForBody(stringResource(StringR.string.common_message_removed)) }
|
||||||
|
is TimelineItemUnknownContent -> content = { ContentForBody(stringResource(StringR.string.common_unsupported_event)) }
|
||||||
|
is TimelineItemImageContent -> {
|
||||||
|
icon = {
|
||||||
|
val mediaRequestData = MediaRequestData(
|
||||||
|
source = event.content.mediaSource,
|
||||||
|
kind = MediaRequestData.Kind.Thumbnail(32),
|
||||||
|
)
|
||||||
|
BlurHashAsyncImage(
|
||||||
|
model = mediaRequestData,
|
||||||
|
blurHash = event.content.blurhash,
|
||||||
|
contentDescription = stringResource(StringR.string.common_image),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = imageModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
content = { ContentForBody(event.content.body) }
|
||||||
|
}
|
||||||
|
is TimelineItemVideoContent -> {
|
||||||
|
icon = {
|
||||||
|
val thumbnailSource = event.content.thumbnailSource
|
||||||
|
if (thumbnailSource != null) {
|
||||||
|
val mediaRequestData = MediaRequestData(
|
||||||
|
source = event.content.thumbnailSource,
|
||||||
|
kind = MediaRequestData.Kind.Thumbnail(32),
|
||||||
|
)
|
||||||
|
BlurHashAsyncImage(
|
||||||
|
model = mediaRequestData,
|
||||||
|
blurHash = event.content.blurHash,
|
||||||
|
contentDescription = stringResource(StringR.string.common_video),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = imageModifier,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.VideoCameraBack,
|
||||||
|
contentDescription = stringResource(StringR.string.common_video),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = { ContentForBody(event.content.body) }
|
||||||
|
}
|
||||||
|
is TimelineItemFileContent -> {
|
||||||
|
icon = {
|
||||||
|
Box(
|
||||||
|
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Attachment,
|
||||||
|
contentDescription = stringResource(StringR.string.common_file),
|
||||||
|
modifier = Modifier.rotate(-45f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = { ContentForBody(event.content.body) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(modifier = modifier) {
|
||||||
|
icon()
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
|
Row {
|
||||||
|
if (event.senderDisplayName != null) {
|
||||||
|
Text(
|
||||||
|
text = event.senderDisplayName,
|
||||||
|
style = ElementTextStyles.Bold.caption1,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
event.sentTime,
|
||||||
|
style = ElementTextStyles.Regular.caption2,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EmojiReactionsRow(modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = modifier.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
// TODO use real emojis, have real interaction
|
||||||
|
Text("\uD83D\uDC4D", fontSize = 28.dpToSp())
|
||||||
|
Text("\uD83D\uDC4E", fontSize = 28.dpToSp())
|
||||||
|
Text("\uD83D\uDD25", fontSize = 28.dpToSp())
|
||||||
|
Text("❤\uFE0F", fontSize = 28.dpToSp())
|
||||||
|
Text("\uD83D\uDC4F", fontSize = 28.dpToSp())
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.AddReaction,
|
||||||
|
contentDescription = "Emojis",
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) {
|
||||||
|
return dp.toSp()
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
||||||
|
|
@ -145,14 +320,7 @@ fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) s
|
||||||
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
||||||
ElementPreviewDark { ContentToPreview(state) }
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(state: ActionListState) {
|
private fun ContentToPreview(state: ActionListState) {
|
||||||
ActionListView(
|
SheetContent(state = state)
|
||||||
state = state,
|
|
||||||
modalBottomSheetState = ModalBottomSheetState(
|
|
||||||
initialValue = ModalBottomSheetValue.Expanded
|
|
||||||
),
|
|
||||||
onActionSelected = { _, _ -> }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,11 @@ sealed class TimelineItemAction(
|
||||||
@DrawableRes val icon: Int,
|
@DrawableRes val icon: Int,
|
||||||
val destructive: Boolean = false
|
val destructive: Boolean = false
|
||||||
) {
|
) {
|
||||||
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward)
|
object Forward : TimelineItemAction("Forward", VectorIcons.Forward)
|
||||||
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
|
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
|
||||||
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
|
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
|
||||||
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
|
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
|
||||||
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
|
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
|
||||||
|
object Developer : TimelineItemAction("Developer", VectorIcons.DeveloperMode)
|
||||||
|
object ReportContent : TimelineItemAction("Report content", VectorIcons.ReportContent, destructive = true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,15 +32,20 @@ import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MimeTypes
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
|
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
|
||||||
|
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer
|
||||||
|
import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||||
import io.element.android.libraries.designsystem.R
|
import io.element.android.libraries.designsystem.R
|
||||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
import me.saket.telephoto.zoomable.ZoomSpec
|
import me.saket.telephoto.zoomable.ZoomSpec
|
||||||
|
import me.saket.telephoto.zoomable.ZoomableState
|
||||||
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
||||||
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
||||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
|
|
@ -53,14 +58,24 @@ fun LocalMediaView(
|
||||||
mimeType: String? = localMedia?.mimeType,
|
mimeType: String? = localMedia?.mimeType,
|
||||||
onReady: () -> Unit = {},
|
onReady: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val zoomableState = rememberZoomableState(
|
||||||
|
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
|
||||||
|
)
|
||||||
when {
|
when {
|
||||||
MimeTypes.isImage(mimeType) -> MediaImageView(
|
mimeType.isMimeTypeImage() -> MediaImageView(
|
||||||
|
localMedia = localMedia,
|
||||||
|
zoomableState = zoomableState,
|
||||||
|
onReady = onReady,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
mimeType.isMimeTypeVideo() -> MediaVideoView(
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
onReady = onReady,
|
onReady = onReady,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
MimeTypes.isVideo(mimeType) -> MediaVideoView(
|
mimeType == MimeTypes.Pdf -> MediaPDFView(
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
|
zoomableState = zoomableState,
|
||||||
onReady = onReady,
|
onReady = onReady,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
|
@ -71,6 +86,7 @@ fun LocalMediaView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaImageView(
|
private fun MediaImageView(
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
|
zoomableState: ZoomableState,
|
||||||
onReady: () -> Unit,
|
onReady: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -81,9 +97,6 @@ private fun MediaImageView(
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val zoomableState = rememberZoomableState(
|
|
||||||
zoomSpec = ZoomSpec(maxZoomFactor = 3f)
|
|
||||||
)
|
|
||||||
val zoomableImageState = rememberZoomableImageState(zoomableState)
|
val zoomableImageState = rememberZoomableImageState(zoomableState)
|
||||||
LaunchedEffect(zoomableImageState.isImageDisplayed) {
|
LaunchedEffect(zoomableImageState.isImageDisplayed) {
|
||||||
if (zoomableImageState.isImageDisplayed) {
|
if (zoomableImageState.isImageDisplayed) {
|
||||||
|
|
@ -154,3 +167,22 @@ fun MediaVideoView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MediaPDFView(
|
||||||
|
localMedia: LocalMedia?,
|
||||||
|
zoomableState: ZoomableState,
|
||||||
|
onReady: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val pdfViewerState = rememberPdfViewerState(
|
||||||
|
model = localMedia?.model,
|
||||||
|
zoomableState = zoomableState
|
||||||
|
)
|
||||||
|
LaunchedEffect(pdfViewerState.isLoaded) {
|
||||||
|
if (pdfViewerState.isLoaded) {
|
||||||
|
onReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* 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.local.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ParcelFileDescriptorFactory(private val context: Context) {
|
||||||
|
|
||||||
|
fun create(model: Any?) = runCatching {
|
||||||
|
when (model) {
|
||||||
|
is File -> ParcelFileDescriptor.open(model, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||||
|
is Uri -> context.contentResolver.openFileDescriptor(model, "r")!!
|
||||||
|
else -> error(RuntimeException("Can't handle this model"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/*
|
||||||
|
* 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.local.pdf
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class PdfPage(
|
||||||
|
maxWidth: Int,
|
||||||
|
val pageIndex: Int,
|
||||||
|
private val mutex: Mutex,
|
||||||
|
private val pdfRenderer: PdfRenderer,
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
|
||||||
|
sealed interface State {
|
||||||
|
data class Loading(val width: Int, val height: Int) : State
|
||||||
|
data class Loaded(val bitmap: Bitmap) : State
|
||||||
|
}
|
||||||
|
|
||||||
|
private val renderWidth = maxWidth
|
||||||
|
private val renderHeight: Int
|
||||||
|
private var loadJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// We are just opening and closing the page to extract data so we can build the Loading state with the correct dimensions.
|
||||||
|
pdfRenderer.openPage(pageIndex).use { page ->
|
||||||
|
renderHeight = (page.height * (renderWidth.toFloat() / page.width)).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mutableStateFlow = MutableStateFlow<State>(
|
||||||
|
State.Loading(
|
||||||
|
width = renderWidth,
|
||||||
|
height = renderHeight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val stateFlow: StateFlow<State> = mutableStateFlow
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
loadJob = coroutineScope.launch {
|
||||||
|
val bitmap = mutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
pdfRenderer.openPageRenderAndClose(pageIndex, renderWidth, renderHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutableStateFlow.value = State.Loaded(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
loadJob?.cancel()
|
||||||
|
when (val loadingState = stateFlow.value) {
|
||||||
|
is State.Loading -> return
|
||||||
|
is State.Loaded -> {
|
||||||
|
loadingState.bitmap.recycle()
|
||||||
|
mutableStateFlow.value = State.Loading(
|
||||||
|
width = renderWidth,
|
||||||
|
height = renderHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PdfRenderer.openPageRenderAndClose(index: Int, bitmapWidth: Int, bitmapHeight: Int): Bitmap {
|
||||||
|
fun createBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap {
|
||||||
|
val bitmap = Bitmap.createBitmap(
|
||||||
|
bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888
|
||||||
|
)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
canvas.drawColor(Color.WHITE)
|
||||||
|
canvas.drawBitmap(bitmap, 0f, 0f, null)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
return openPage(index).use { page ->
|
||||||
|
createBitmap(bitmapWidth, bitmapHeight).apply {
|
||||||
|
page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/*
|
||||||
|
* 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.local.pdf
|
||||||
|
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class PdfRendererManager(
|
||||||
|
private val parcelFileDescriptor: ParcelFileDescriptor,
|
||||||
|
private val width: Int,
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var pdfRenderer: PdfRenderer? = null
|
||||||
|
private val mutablePdfPages = MutableStateFlow<List<PdfPage>>(emptyList())
|
||||||
|
val pdfPages: StateFlow<List<PdfPage>> = mutablePdfPages
|
||||||
|
|
||||||
|
fun open() {
|
||||||
|
coroutineScope.launch {
|
||||||
|
mutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
pdfRenderer = PdfRenderer(parcelFileDescriptor).apply {
|
||||||
|
(0 until pageCount).map { pageIndex ->
|
||||||
|
PdfPage(width, pageIndex, mutex, this, coroutineScope)
|
||||||
|
}.also {
|
||||||
|
mutablePdfPages.value = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
coroutineScope.launch {
|
||||||
|
mutex.withLock {
|
||||||
|
mutablePdfPages.value.forEach { pdfPage ->
|
||||||
|
pdfPage.close()
|
||||||
|
}
|
||||||
|
pdfRenderer?.close()
|
||||||
|
parcelFileDescriptor.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
/*
|
||||||
|
* 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.local.pdf
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import me.saket.telephoto.zoomable.zoomable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PdfViewer(
|
||||||
|
pdfViewerState: PdfViewerState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier.zoomable(pdfViewerState.zoomableState),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val maxWidthInPx = maxWidth.dpToPx()
|
||||||
|
DisposableEffect(pdfViewerState) {
|
||||||
|
pdfViewerState.openForWidth(maxWidthInPx)
|
||||||
|
onDispose {
|
||||||
|
pdfViewerState.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pdfPages = pdfViewerState.getPages()
|
||||||
|
PdfPagesView(pdfPages.toImmutableList(), pdfViewerState.lazyListState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfPagesView(
|
||||||
|
pdfPages: ImmutableList<PdfPage>,
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
state = lazyListState,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically)
|
||||||
|
|
||||||
|
) {
|
||||||
|
items(pdfPages.size) { index ->
|
||||||
|
val pdfPage = pdfPages[index]
|
||||||
|
PdfPageView(pdfPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfPageView(
|
||||||
|
pdfPage: PdfPage,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val pdfPageState by pdfPage.stateFlow.collectAsState()
|
||||||
|
DisposableEffect(pdfPage) {
|
||||||
|
pdfPage.load()
|
||||||
|
onDispose {
|
||||||
|
pdfPage.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (val state = pdfPageState) {
|
||||||
|
is PdfPage.State.Loaded -> {
|
||||||
|
Image(
|
||||||
|
bitmap = state.bitmap.asImageBitmap(),
|
||||||
|
contentDescription = "Page ${pdfPage.pageIndex}",
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is PdfPage.State.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(state.height.pxToDp())
|
||||||
|
.background(color = Color.White)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.roundToPx() }
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
* 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.local.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
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.platform.LocalContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import me.saket.telephoto.zoomable.ZoomableState
|
||||||
|
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class PdfViewerState(
|
||||||
|
private val model: Any?,
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val context: Context,
|
||||||
|
val zoomableState: ZoomableState,
|
||||||
|
val lazyListState: LazyListState,
|
||||||
|
) {
|
||||||
|
|
||||||
|
var isLoaded by mutableStateOf(false)
|
||||||
|
private var pdfRendererManager by mutableStateOf<PdfRendererManager?>(null)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun getPages(): List<PdfPage>{
|
||||||
|
return pdfRendererManager?.run {
|
||||||
|
pdfPages.collectAsState().value
|
||||||
|
}?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openForWidth(maxWidth: Int) {
|
||||||
|
ParcelFileDescriptorFactory(context).create(model)
|
||||||
|
.onSuccess {
|
||||||
|
pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply {
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
isLoaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
pdfRendererManager?.close()
|
||||||
|
isLoaded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberPdfViewerState(
|
||||||
|
model: Any?,
|
||||||
|
zoomableState: ZoomableState = rememberZoomableState(),
|
||||||
|
lazyListState: LazyListState = rememberLazyListState(),
|
||||||
|
context: Context = LocalContext.current,
|
||||||
|
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||||
|
): PdfViewerState {
|
||||||
|
return remember(model) {
|
||||||
|
PdfViewerState(
|
||||||
|
model = model,
|
||||||
|
coroutineScope = coroutineScope,
|
||||||
|
context = context,
|
||||||
|
zoomableState = zoomableState,
|
||||||
|
lazyListState = lazyListState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
/*
|
||||||
|
* 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.messagecomposer
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
|
import androidx.compose.material.ListItem
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Collections
|
||||||
|
import androidx.compose.material.icons.filled.PhotoCamera
|
||||||
|
import androidx.compose.material.icons.filled.Videocam
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.messages.impl.R
|
||||||
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun AttachmentsBottomSheet(
|
||||||
|
state: MessageComposerState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val localView = LocalView.current
|
||||||
|
var isVisible by rememberSaveable { mutableStateOf(state.showAttachmentSourcePicker) }
|
||||||
|
|
||||||
|
BackHandler(enabled = isVisible) {
|
||||||
|
isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.showAttachmentSourcePicker) {
|
||||||
|
if (state.showAttachmentSourcePicker) {
|
||||||
|
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
||||||
|
localView.hideKeyboard()
|
||||||
|
isVisible = true
|
||||||
|
} else {
|
||||||
|
isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
|
||||||
|
LaunchedEffect(isVisible) {
|
||||||
|
if (!isVisible) {
|
||||||
|
state.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
modifier = modifier,
|
||||||
|
onDismissRequest = { isVisible = false }
|
||||||
|
) {
|
||||||
|
AttachmentSourcePickerMenu(eventSink = state.eventSink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun AttachmentSourcePickerMenu(
|
||||||
|
eventSink: (MessageComposerEvents) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier.padding(bottom = 32.dp)
|
||||||
|
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||||
|
icon = { Icon(Icons.Default.Collections, null) },
|
||||||
|
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||||
|
icon = { Icon(Icons.Default.AttachFile, null) },
|
||||||
|
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||||
|
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
||||||
|
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||||
|
icon = { Icon(Icons.Default.Videocam, null) },
|
||||||
|
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.messagecomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
@ -46,21 +47,25 @@ fun MessageComposerView(
|
||||||
state.eventSink(MessageComposerEvents.UpdateText(text))
|
state.eventSink(MessageComposerEvents.UpdateText(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
TextComposer(
|
Box {
|
||||||
onSendMessage = ::sendMessage,
|
AttachmentsBottomSheet(state = state)
|
||||||
fullscreen = state.isFullScreen,
|
|
||||||
onFullscreenToggle = ::onFullscreenToggle,
|
TextComposer(
|
||||||
composerMode = state.mode,
|
onSendMessage = ::sendMessage,
|
||||||
onCloseSpecialMode = ::onCloseSpecialMode,
|
fullscreen = state.isFullScreen,
|
||||||
onComposerTextChange = ::onComposerTextChange,
|
onFullscreenToggle = ::onFullscreenToggle,
|
||||||
onAddAttachment = {
|
composerMode = state.mode,
|
||||||
state.eventSink(MessageComposerEvents.AddAttachment)
|
onCloseSpecialMode = ::onCloseSpecialMode,
|
||||||
},
|
onComposerTextChange = ::onComposerTextChange,
|
||||||
composerCanSendMessage = state.isSendButtonVisible,
|
onAddAttachment = {
|
||||||
composerText = state.text?.charSequence?.toString(),
|
state.eventSink(MessageComposerEvents.AddAttachment)
|
||||||
isInDarkMode = !ElementTheme.colors.isLight,
|
},
|
||||||
modifier = modifier
|
composerCanSendMessage = state.isSendButtonVisible,
|
||||||
)
|
composerText = state.text?.charSequence?.toString(),
|
||||||
|
isInDarkMode = !ElementTheme.colors.isLight,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
|
|
||||||
|
|
@ -18,21 +18,24 @@ package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Attachment
|
import androidx.compose.material.icons.outlined.Attachment
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
|
@ -47,7 +50,6 @@ fun TimelineItemFileView(
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -57,16 +59,23 @@ fun TimelineItemFileView(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Attachment,
|
imageVector = Icons.Outlined.Attachment,
|
||||||
contentDescription = "OpenFile"
|
contentDescription = "OpenFile",
|
||||||
|
modifier = Modifier.size(16.dp).rotate(-45f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp),) {
|
||||||
|
Text(
|
||||||
|
text = content.body,
|
||||||
|
maxLines = 2,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = content.fileExtensionAndSize,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = content.body,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
|
||||||
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
|
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||||
|
|
@ -34,7 +35,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class TimelineItemContentMessageFactory @Inject constructor() {
|
class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
|
private val fileSizeFormatter: FileSizeFormatter
|
||||||
|
) {
|
||||||
|
|
||||||
fun create(content: MessageContent): TimelineItemEventContent {
|
fun create(content: MessageContent): TimelineItemEventContent {
|
||||||
return when (val messageType = content.type) {
|
return when (val messageType = content.type) {
|
||||||
|
|
@ -74,7 +77,9 @@ class TimelineItemContentMessageFactory @Inject constructor() {
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
fileSource = messageType.source,
|
fileSource = messageType.source,
|
||||||
mimeType = messageType.info?.mimetype,
|
mimeType = messageType.info?.mimetype,
|
||||||
size = messageType.info?.size,
|
formattedFileSize = messageType.info?.size?.let {
|
||||||
|
fileSizeFormatter.format(it)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
is NoticeMessageType -> TimelineItemNoticeContent(
|
is NoticeMessageType -> TimelineItemNoticeContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,17 @@ data class TimelineItemFileContent(
|
||||||
val body: String,
|
val body: String,
|
||||||
val fileSource: MediaSource,
|
val fileSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val size: Long?,
|
val formattedFileSize: String?,
|
||||||
val mimeType: String?,
|
val mimeType: String?,
|
||||||
) : TimelineItemEventContent {
|
) : TimelineItemEventContent {
|
||||||
override val type: String = "TimelineItemFileContent"
|
override val type: String = "TimelineItemFileContent"
|
||||||
|
|
||||||
|
private val fileExtension = body.substringAfterLast('.', "").uppercase()
|
||||||
|
val fileExtensionAndSize = buildString {
|
||||||
|
append(fileExtension)
|
||||||
|
if (formattedFileSize != null) {
|
||||||
|
append(' ')
|
||||||
|
append("($formattedFileSize)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aTimelineItemFileContent("A file.pdf"),
|
aTimelineItemFileContent("A file.pdf"),
|
||||||
aTimelineItemFileContent("A bigger name file.pdf"),
|
aTimelineItemFileContent("A bigger name file.pdf"),
|
||||||
aTimelineItemFileContent("An even bigger file name which doesn't fit.pdf"),
|
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,5 +34,5 @@ fun aTimelineItemFileContent(fileName: String) = TimelineItemFileContent(
|
||||||
thumbnailSource = MediaSource(url = ""),
|
thumbnailSource = MediaSource(url = ""),
|
||||||
fileSource = MediaSource(url = ""),
|
fileSource = MediaSource(url = ""),
|
||||||
mimeType = MimeTypes.OctetStream,
|
mimeType = MimeTypes.OctetStream,
|
||||||
size = 100
|
formattedFileSize = "100kB"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.timeline.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.format.Formatter
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface FileSizeFormatter {
|
||||||
|
/**
|
||||||
|
* Formats a content size to be in the form of bytes, kilobytes, megabytes, etc.
|
||||||
|
*/
|
||||||
|
fun format(fileSize: Long): String
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class AndroidFileSizeFormatter @Inject constructor(@ApplicationContext private val context: Context) : FileSizeFormatter {
|
||||||
|
override fun format(fileSize: Long): String {
|
||||||
|
return Formatter.formatShortFileSize(context, fileSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
|
@ -132,6 +134,32 @@ class MessagesPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action report content`() = runTest {
|
||||||
|
val presenter = createMessagePresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
|
||||||
|
// Still a TODO in the code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action show developer info`() = runTest {
|
||||||
|
val presenter = createMessagePresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
|
||||||
|
// Still a TODO in the code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun TestScope.createMessagePresenter(
|
private fun TestScope.createMessagePresenter(
|
||||||
matrixRoom: MatrixRoom = FakeMatrixRoom()
|
matrixRoom: MatrixRoom = FakeMatrixRoom()
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
|
|
@ -148,7 +176,20 @@ class MessagesPresenterTest {
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
)
|
)
|
||||||
val actionListPresenter = ActionListPresenter()
|
val buildMeta = BuildMeta(
|
||||||
|
buildType = BuildType.DEBUG,
|
||||||
|
isDebuggable = true,
|
||||||
|
applicationId = "",
|
||||||
|
applicationName = "",
|
||||||
|
lowPrivacyLoggingEnabled = true,
|
||||||
|
versionName = "",
|
||||||
|
gitRevision = "",
|
||||||
|
gitBranchName = "",
|
||||||
|
gitRevisionDate = "",
|
||||||
|
flavorDescription = "",
|
||||||
|
flavorShortDescription = "",
|
||||||
|
)
|
||||||
|
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
composerPresenter = messageComposerPresenter,
|
composerPresenter = messageComposerPresenter,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
|
@ -42,7 +44,7 @@ import org.junit.Test
|
||||||
class ActionListPresenterTest {
|
class ActionListPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -53,7 +55,7 @@ class ActionListPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute for message from me redacted`() = runTest {
|
fun `present - compute for message from me redacted`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -67,6 +69,8 @@ class ActionListPresenterTest {
|
||||||
ActionListState.Target.Success(
|
ActionListState.Target.Success(
|
||||||
messageEvent,
|
messageEvent,
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -75,9 +79,10 @@ class ActionListPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute for message from others redacted`() = runTest {
|
fun `present - compute for message from others redacted`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -91,6 +96,8 @@ class ActionListPresenterTest {
|
||||||
ActionListState.Target.Success(
|
ActionListState.Target.Success(
|
||||||
messageEvent,
|
messageEvent,
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -101,7 +108,7 @@ class ActionListPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute for others message`() = runTest {
|
fun `present - compute for others message`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -121,6 +128,8 @@ class ActionListPresenterTest {
|
||||||
TimelineItemAction.Reply,
|
TimelineItemAction.Reply,
|
||||||
TimelineItemAction.Forward,
|
TimelineItemAction.Forward,
|
||||||
TimelineItemAction.Copy,
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
|
TimelineItemAction.ReportContent,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -131,7 +140,7 @@ class ActionListPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute for my message`() = runTest {
|
fun `present - compute for my message`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -150,8 +159,41 @@ class ActionListPresenterTest {
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
TimelineItemAction.Reply,
|
TimelineItemAction.Reply,
|
||||||
TimelineItemAction.Forward,
|
TimelineItemAction.Forward,
|
||||||
TimelineItemAction.Copy,
|
|
||||||
TimelineItemAction.Edit,
|
TimelineItemAction.Edit,
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
|
TimelineItemAction.Redact,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||||
|
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute message in non-debuggable build`() = runTest {
|
||||||
|
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
|
||||||
|
// val loadingState = awaitItem()
|
||||||
|
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.target).isEqualTo(
|
||||||
|
ActionListState.Target.Success(
|
||||||
|
messageEvent,
|
||||||
|
persistentListOf(
|
||||||
|
TimelineItemAction.Reply,
|
||||||
|
TimelineItemAction.Forward,
|
||||||
|
TimelineItemAction.Edit,
|
||||||
|
TimelineItemAction.Copy,
|
||||||
TimelineItemAction.Redact,
|
TimelineItemAction.Redact,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -162,6 +204,34 @@ class ActionListPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun aBuildMeta(
|
||||||
|
buildType: BuildType = BuildType.DEBUG,
|
||||||
|
isDebuggable: Boolean = true,
|
||||||
|
applicationName: String = "",
|
||||||
|
applicationId: String = "",
|
||||||
|
lowPrivacyLoggingEnabled: Boolean = true,
|
||||||
|
versionName: String = "",
|
||||||
|
gitRevision: String = "",
|
||||||
|
gitRevisionDate: String = "",
|
||||||
|
gitBranchName: String = "",
|
||||||
|
flavorDescription: String = "",
|
||||||
|
flavorShortDescription: String = "",
|
||||||
|
) = BuildMeta(
|
||||||
|
buildType,
|
||||||
|
isDebuggable,
|
||||||
|
applicationName,
|
||||||
|
applicationId,
|
||||||
|
lowPrivacyLoggingEnabled,
|
||||||
|
versionName,
|
||||||
|
gitRevision,
|
||||||
|
gitRevisionDate,
|
||||||
|
gitBranchName,
|
||||||
|
flavorDescription,
|
||||||
|
flavorShortDescription
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))
|
||||||
|
|
||||||
private fun aMessageEvent(
|
private fun aMessageEvent(
|
||||||
isMine: Boolean,
|
isMine: Boolean,
|
||||||
content: TimelineItemEventContent,
|
content: TimelineItemEventContent,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
|
||||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
|
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
||||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
||||||
|
import io.element.android.features.messages.timeline.FakeFileSizeFormatter
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||||
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
|
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||||
|
|
@ -42,7 +43,7 @@ internal fun aTimelineItemsFactory(): TimelineItemsFactory {
|
||||||
dispatchers = testCoroutineDispatchers(),
|
dispatchers = testCoroutineDispatchers(),
|
||||||
eventItemFactory = TimelineItemEventFactory(
|
eventItemFactory = TimelineItemEventFactory(
|
||||||
TimelineItemContentFactory(
|
TimelineItemContentFactory(
|
||||||
messageFactory = TimelineItemContentMessageFactory(),
|
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter()),
|
||||||
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
||||||
stickerFactory = TimelineItemContentStickerFactory(),
|
stickerFactory = TimelineItemContentStickerFactory(),
|
||||||
utdFactory = TimelineItemContentUTDFactory(),
|
utdFactory = TimelineItemContentUTDFactory(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
|
||||||
|
|
||||||
|
class FakeFileSizeFormatter : FileSizeFormatter {
|
||||||
|
override fun format(fileSize: Long): String {
|
||||||
|
return "$fileSize Bytes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,8 +40,6 @@ dependencies {
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(libs.accompanist.pager)
|
|
||||||
implementation(libs.accompanist.pagerindicator)
|
|
||||||
api(projects.features.onboarding.api)
|
api(projects.features.onboarding.api)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.onboarding.impl
|
package io.element.android.features.onboarding.impl
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
object OnBoardingConfig {
|
||||||
import androidx.annotation.StringRes
|
const val canLoginWithQrCode = false
|
||||||
|
const val canCreateAccount = false
|
||||||
data class SplashCarouselData(
|
|
||||||
val items: List<Item>
|
|
||||||
) {
|
|
||||||
data class Item(
|
|
||||||
@StringRes val title: Int,
|
|
||||||
@StringRes val body: Int,
|
|
||||||
@DrawableRes val image: Int,
|
|
||||||
@DrawableRes val pageBackground: Int
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ import io.element.android.libraries.di.AppScope
|
||||||
class OnBoardingNode @AssistedInject constructor(
|
class OnBoardingNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: OnBoardingPresenter,
|
||||||
) : Node(
|
) : Node(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins
|
plugins = plugins
|
||||||
|
|
@ -47,10 +48,11 @@ class OnBoardingNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
OnBoardingScreen(
|
val state = presenter.present()
|
||||||
|
OnBoardingView(
|
||||||
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onSignIn = this::onSignIn,
|
onSignIn = ::onSignIn,
|
||||||
onSignUp = this::onSignUp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* 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.onboarding.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: this Presenter is ignored regarding code coverage because it cannot reach the coverage threshold.
|
||||||
|
* When this presenter get more code in it, please remove the ignore rule in the kover configuration.
|
||||||
|
*/
|
||||||
|
class OnBoardingPresenter @Inject constructor(
|
||||||
|
) : Presenter<OnBoardingState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): OnBoardingState {
|
||||||
|
return OnBoardingState(
|
||||||
|
canLoginWithQrCode = OnBoardingConfig.canLoginWithQrCode,
|
||||||
|
canCreateAccount = OnBoardingConfig.canCreateAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
/*
|
|
||||||
* 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.onboarding.impl
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.systemBarsPadding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
|
||||||
import com.google.accompanist.pager.HorizontalPagerIndicator
|
|
||||||
import com.google.accompanist.pager.rememberPagerState
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
|
||||||
import io.element.android.libraries.testtags.TestTags
|
|
||||||
import io.element.android.libraries.testtags.testTag
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OnBoardingScreen(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onPageChanged: (Int) -> Unit = {},
|
|
||||||
onSignUp: () -> Unit = {},
|
|
||||||
onSignIn: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val carrouselData = remember { SplashCarouselDataFactory().create() }
|
|
||||||
val nbOfPages = carrouselData.items.size
|
|
||||||
var key by remember { mutableStateOf(false) }
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.systemBarsPadding()
|
|
||||||
.padding(vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
val pagerState = rememberPagerState()
|
|
||||||
LaunchedEffect(key) {
|
|
||||||
launch {
|
|
||||||
delay(3_000)
|
|
||||||
pagerState.animateScrollToPage((pagerState.currentPage + 1) % nbOfPages)
|
|
||||||
// https://stackoverflow.com/questions/73714228/accompanist-pager-animatescrolltopage-doesnt-scroll-to-next-page-correctly
|
|
||||||
key = !key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(pagerState) {
|
|
||||||
// Collect from the pager state a snapshotFlow reading the currentPage
|
|
||||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
|
||||||
onPageChanged(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalPager(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
count = nbOfPages,
|
|
||||||
state = pagerState,
|
|
||||||
) { page ->
|
|
||||||
// Our page content
|
|
||||||
OnBoardingPage(carrouselData.items[page])
|
|
||||||
}
|
|
||||||
HorizontalPagerIndicator(
|
|
||||||
pagerState = pagerState,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(CenterHorizontally)
|
|
||||||
.padding(16.dp),
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onSignIn()
|
|
||||||
},
|
|
||||||
enabled = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(CenterHorizontally)
|
|
||||||
.testTag(TestTags.onBoardingSignIn)
|
|
||||||
.padding(top = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = R.string.login_splash_submit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OnBoardingPage(
|
|
||||||
item: SplashCarouselData.Item,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
/*
|
|
||||||
Image(
|
|
||||||
painterResource(id = item.pageBackground),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(vertical = 16.dp, horizontal = 32.dp)
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painterResource(id = item.image),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(CenterHorizontally)
|
|
||||||
.size(192.dp)
|
|
||||||
.padding(16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = item.title),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(CenterHorizontally)
|
|
||||||
.padding(8.dp),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontSize = 24.sp,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = item.body),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(CenterHorizontally),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun OnBoardingScreenLightPreview() =
|
|
||||||
ElementPreviewLight { ContentToPreview() }
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun OnBoardingScreenDarkPreview() =
|
|
||||||
ElementPreviewDark { ContentToPreview() }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ContentToPreview() {
|
|
||||||
OnBoardingScreen()
|
|
||||||
}
|
|
||||||
|
|
@ -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.onboarding.impl
|
||||||
|
|
||||||
|
data class OnBoardingState(
|
||||||
|
val canLoginWithQrCode: Boolean,
|
||||||
|
val canCreateAccount: Boolean,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* 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.onboarding.impl
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
|
open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
|
||||||
|
override val values: Sequence<OnBoardingState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
anOnBoardingState(),
|
||||||
|
anOnBoardingState(canLoginWithQrCode = true),
|
||||||
|
anOnBoardingState(canCreateAccount = true),
|
||||||
|
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun anOnBoardingState(
|
||||||
|
canLoginWithQrCode: Boolean = false,
|
||||||
|
canCreateAccount: Boolean = false
|
||||||
|
) = OnBoardingState(
|
||||||
|
canLoginWithQrCode = canLoginWithQrCode,
|
||||||
|
canCreateAccount = canCreateAccount
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
/*
|
||||||
|
* 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.onboarding.impl
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.QrCode
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||||
|
import androidx.compose.ui.BiasAlignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.testtags.TestTags
|
||||||
|
import io.element.android.libraries.testtags.testTag
|
||||||
|
|
||||||
|
// Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
|
||||||
|
@Composable
|
||||||
|
fun OnBoardingView(
|
||||||
|
state: OnBoardingState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onSignInWithQrCode: () -> Unit = {},
|
||||||
|
onSignIn: () -> Unit = {},
|
||||||
|
onCreateAccount: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
OnBoardingPage(
|
||||||
|
modifier = modifier,
|
||||||
|
footer = {
|
||||||
|
OnBoardingButtons(
|
||||||
|
state = state,
|
||||||
|
onSignInWithQrCode = onSignInWithQrCode,
|
||||||
|
onSignIn = onSignIn,
|
||||||
|
onCreateAccount = onCreateAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
OnBoardingContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnBoardingContent(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = BiasAlignment(
|
||||||
|
horizontalBias = 0f,
|
||||||
|
verticalBias = -0.2f
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.element_logo),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Image(
|
||||||
|
modifier = Modifier.padding(top = 14.dp),
|
||||||
|
painter = painterResource(id = R.drawable.element),
|
||||||
|
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
text = stringResource(id = R.string.screen_onboarding_subtitle),
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnBoardingButtons(
|
||||||
|
state: OnBoardingState,
|
||||||
|
onSignInWithQrCode: () -> Unit,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
onCreateAccount: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
if (state.canLoginWithQrCode) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSignInWithQrCode()
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.QrCode, contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Text(text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSignIn()
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(TestTags.onBoardingSignIn)
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.screen_onboarding_sign_in_manually))
|
||||||
|
}
|
||||||
|
if (state.canCreateAccount) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
onCreateAccount()
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.screen_onboarding_sign_up))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun OnBoardingScreenLightPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun OnBoardingScreenDarkPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: OnBoardingState) {
|
||||||
|
OnBoardingView(state)
|
||||||
|
}
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
/*
|
|
||||||
* 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.onboarding.impl
|
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
|
|
||||||
class SplashCarouselDataFactory {
|
|
||||||
fun create(): SplashCarouselData {
|
|
||||||
val lightTheme = true
|
|
||||||
|
|
||||||
fun background(@DrawableRes lightDrawable: Int) =
|
|
||||||
if (lightTheme) lightDrawable else R.drawable.bg_color_background
|
|
||||||
|
|
||||||
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
|
|
||||||
if (lightTheme) lightDrawable else darkDrawable
|
|
||||||
|
|
||||||
return SplashCarouselData(
|
|
||||||
listOf(
|
|
||||||
SplashCarouselData.Item(
|
|
||||||
R.string.ftue_auth_carousel_secure_title,
|
|
||||||
R.string.ftue_auth_carousel_secure_body,
|
|
||||||
hero(
|
|
||||||
R.drawable.ic_splash_conversations,
|
|
||||||
R.drawable.ic_splash_conversations_dark
|
|
||||||
),
|
|
||||||
background(R.drawable.bg_carousel_page_1)
|
|
||||||
),
|
|
||||||
SplashCarouselData.Item(
|
|
||||||
R.string.ftue_auth_carousel_control_title,
|
|
||||||
R.string.ftue_auth_carousel_control_body,
|
|
||||||
hero(R.drawable.ic_splash_control, R.drawable.ic_splash_control_dark),
|
|
||||||
background(R.drawable.bg_carousel_page_2)
|
|
||||||
),
|
|
||||||
SplashCarouselData.Item(
|
|
||||||
R.string.ftue_auth_carousel_encrypted_title,
|
|
||||||
R.string.ftue_auth_carousel_encrypted_body,
|
|
||||||
hero(R.drawable.ic_splash_secure, R.drawable.ic_splash_secure_dark),
|
|
||||||
background(R.drawable.bg_carousel_page_3)
|
|
||||||
),
|
|
||||||
SplashCarouselData.Item(
|
|
||||||
collaborationTitle(),
|
|
||||||
R.string.ftue_auth_carousel_workplace_body,
|
|
||||||
hero(
|
|
||||||
R.drawable.ic_splash_collaboration,
|
|
||||||
R.drawable.ic_splash_collaboration_dark
|
|
||||||
),
|
|
||||||
background(R.drawable.bg_carousel_page_4)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun collaborationTitle(): Int {
|
|
||||||
return when {
|
|
||||||
true -> R.string.cut_the_slack_from_teams
|
|
||||||
else -> R.string.ftue_auth_carousel_workplace_title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (c) 2022 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<gradient
|
|
||||||
android:angle="@integer/rtl_mirror_flip"
|
|
||||||
android:endColor="#3372C7DA"
|
|
||||||
android:startColor="#33BBE7CF" />
|
|
||||||
</shape>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (c) 2022 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<gradient
|
|
||||||
android:angle="@integer/rtl_mirror_flip"
|
|
||||||
android:endColor="#33B972DA"
|
|
||||||
android:startColor="#3372C7DA" />
|
|
||||||
</shape>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (c) 2022 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<gradient
|
|
||||||
android:angle="@integer/rtl_mirror_flip"
|
|
||||||
android:endColor="#330DBD8B"
|
|
||||||
android:startColor="#33B972DA" />
|
|
||||||
</shape>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (c) 2022 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<gradient
|
|
||||||
android:angle="@integer/rtl_mirror_flip"
|
|
||||||
android:endColor="#33BBE7CF"
|
|
||||||
android:startColor="#330DBD8B" />
|
|
||||||
</shape>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (c) 2022 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<solid android:color="?android:colorBackground" />
|
|
||||||
</shape>
|
|
||||||
27
features/onboarding/impl/src/main/res/drawable/element.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="160dp"
|
||||||
|
android:height="34dp"
|
||||||
|
android:viewportWidth="160"
|
||||||
|
android:viewportHeight="34">
|
||||||
|
<path
|
||||||
|
android:pathData="M22.18,23.71H5.07C5.27,25.51 5.92,26.94 7.02,28.01C8.12,29.05 9.56,29.57 11.35,29.57C12.53,29.57 13.6,29.28 14.56,28.71C15.51,28.13 16.19,27.35 16.59,26.36H21.79C21.1,28.65 19.8,30.5 17.89,31.92C16.01,33.31 13.79,34 11.22,34C7.87,34 5.16,32.89 3.08,30.66C1.03,28.43 0,25.61 0,22.2C0,18.87 1.04,16.08 3.12,13.82C5.2,11.56 7.88,10.43 11.18,10.43C14.47,10.43 17.13,11.55 19.15,13.78C21.2,15.97 22.22,18.75 22.22,22.11L22.18,23.71ZM11.18,14.64C9.56,14.64 8.22,15.12 7.15,16.08C6.08,17.03 5.42,18.3 5.16,19.9H17.11C16.88,18.3 16.25,17.03 15.21,16.08C14.17,15.12 12.82,14.64 11.18,14.64Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M25.77,26.75V0.93H30.93V26.84C30.93,28 31.56,28.58 32.83,28.58L33.74,28.53V33.44C33.25,33.52 32.73,33.57 32.18,33.57C29.96,33.57 28.33,33 27.29,31.87C26.28,30.75 25.77,29.04 25.77,26.75Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M57.75,23.71H40.64C40.84,25.51 41.49,26.94 42.59,28.01C43.69,29.05 45.13,29.57 46.92,29.57C48.11,29.57 49.18,29.28 50.13,28.71C51.08,28.13 51.76,27.35 52.16,26.36H57.36C56.67,28.65 55.37,30.5 53.46,31.92C51.59,33.31 49.36,34 46.79,34C43.44,34 40.73,32.89 38.65,30.66C36.6,28.43 35.57,25.61 35.57,22.2C35.57,18.87 36.61,16.08 38.69,13.82C40.77,11.56 43.46,10.43 46.75,10.43C50.04,10.43 52.7,11.55 54.72,13.78C56.77,15.97 57.8,18.75 57.8,22.11L57.75,23.71ZM46.75,14.64C45.13,14.64 43.79,15.12 42.72,16.08C41.65,17.03 40.99,18.3 40.73,19.9H52.68C52.45,18.3 51.82,17.03 50.78,16.08C49.74,15.12 48.4,14.64 46.75,14.64Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M81.01,20.55V33.48H75.86V19.98C75.86,16.57 74.44,14.86 71.61,14.86C70.08,14.86 68.85,15.35 67.93,16.34C67.03,17.32 66.59,18.67 66.59,20.37V33.48H61.43V10.95H66.2V13.95C66.75,12.94 67.58,12.1 68.71,11.43C69.84,10.77 71.24,10.43 72.91,10.43C76.03,10.43 78.28,11.62 79.67,13.99C81.58,11.62 84.12,10.43 87.29,10.43C89.92,10.43 91.94,11.26 93.36,12.91C94.77,14.53 95.48,16.67 95.48,19.33V33.48H90.33V19.98C90.33,16.57 88.91,14.86 86.08,14.86C84.52,14.86 83.28,15.37 82.36,16.38C81.46,17.36 81.01,18.75 81.01,20.55Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M121.12,23.71H104.01C104.21,25.51 104.86,26.94 105.96,28.01C107.06,29.05 108.5,29.57 110.29,29.57C111.47,29.57 112.54,29.28 113.5,28.71C114.45,28.13 115.13,27.35 115.53,26.36H120.73C120.04,28.65 118.74,30.5 116.83,31.92C114.96,33.31 112.73,34 110.16,34C106.81,34 104.1,32.89 102.02,30.66C99.97,28.43 98.94,25.61 98.94,22.2C98.94,18.87 99.98,16.08 102.06,13.82C104.14,11.56 106.82,10.43 110.12,10.43C113.41,10.43 116.07,11.55 118.09,13.78C120.14,15.97 121.16,18.75 121.16,22.11L121.12,23.71ZM110.12,14.64C108.5,14.64 107.16,15.12 106.09,16.08C105.02,17.03 104.36,18.3 104.1,19.9H116.05C115.82,18.3 115.19,17.03 114.15,16.08C113.11,15.12 111.76,14.64 110.12,14.64Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M129.56,10.95V13.95C130.08,12.97 130.94,12.14 132.12,11.48C133.33,10.78 134.79,10.43 136.5,10.43C139.15,10.43 141.2,11.24 142.65,12.86C144.12,14.48 144.86,16.64 144.86,19.33V33.48H139.7V19.98C139.7,18.39 139.33,17.15 138.57,16.25C137.85,15.32 136.74,14.86 135.24,14.86C133.59,14.86 132.29,15.35 131.34,16.34C130.42,17.32 129.95,18.68 129.95,20.42V33.48H124.8V10.95H129.56Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M159.91,28.88V33.35C159.28,33.52 158.38,33.61 157.23,33.61C152.84,33.61 150.64,31.4 150.64,26.97V15.08H147.22V10.95H150.64V5.1H155.8V10.95H160V15.08H155.8V26.45C155.8,28.21 156.63,29.1 158.31,29.1L159.91,28.88Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="50dp"
|
||||||
|
android:height="49dp"
|
||||||
|
android:viewportWidth="50"
|
||||||
|
android:viewportHeight="49">
|
||||||
|
<path
|
||||||
|
android:pathData="M24.8,48.608C38.199,48.608 49.061,37.726 49.061,24.304C49.061,10.881 38.199,0 24.8,0C11.401,0 0.54,10.881 0.54,24.304C0.54,37.726 11.401,48.608 24.8,48.608Z"
|
||||||
|
android:fillColor="#0DBD8B"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M20.365,11.324C20.365,10.343 21.16,9.548 22.142,9.548C28.793,9.548 34.185,14.938 34.185,21.587C34.185,22.568 33.389,23.363 32.408,23.363C31.426,23.363 30.631,22.568 30.631,21.587C30.631,16.9 26.83,13.1 22.142,13.1C21.16,13.1 20.365,12.305 20.365,11.324Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M37.753,19.81C38.735,19.81 39.53,20.606 39.53,21.587C39.53,28.236 34.138,33.626 27.487,33.626C26.506,33.626 25.71,32.831 25.71,31.85C25.71,30.869 26.506,30.073 27.487,30.073C32.176,30.073 35.976,26.274 35.976,21.587C35.976,20.606 36.772,19.81 37.753,19.81Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M29.264,37.283C29.264,38.264 28.468,39.06 27.487,39.06C20.836,39.06 15.444,33.669 15.444,27.02C15.444,26.039 16.24,25.244 17.221,25.244C18.202,25.244 18.998,26.039 18.998,27.02C18.998,31.708 22.799,35.507 27.487,35.507C28.468,35.507 29.264,36.302 29.264,37.283Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M11.847,28.797C10.866,28.797 10.071,28.002 10.071,27.021C10.071,20.372 15.462,14.981 22.113,14.981C23.095,14.981 23.89,15.777 23.89,16.758C23.89,17.739 23.095,18.534 22.113,18.534C17.425,18.534 13.624,22.333 13.624,27.021C13.624,28.002 12.829,28.797 11.847,28.797Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (c) 2022 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<resources>
|
|
||||||
<!-- File to remove once the screen will be updated -->
|
|
||||||
|
|
||||||
<string name="cut_the_slack_from_teams" translatable="false">Cut the slack from teams.</string>
|
|
||||||
|
|
||||||
<string name="login_splash_submit">Get started</string>
|
|
||||||
|
|
||||||
<string name="ftue_auth_carousel_secure_title">Own your conversations.</string>
|
|
||||||
<string name="ftue_auth_carousel_control_title">You\'re in control.</string>
|
|
||||||
<string name="ftue_auth_carousel_encrypted_title">Secure messaging.</string>
|
|
||||||
<string name="ftue_auth_carousel_workplace_title">Messaging for your team.</string>
|
|
||||||
|
|
||||||
<string name="ftue_auth_carousel_secure_body">Secure and independent communication that gives you the same level of privacy as a face-to-face conversation in your own home.</string>
|
|
||||||
<string name="ftue_auth_carousel_control_body">Choose where your conversations are kept, giving you control and independence. Connected via Matrix.</string>
|
|
||||||
<string name="ftue_auth_carousel_encrypted_body">End-to-end encrypted and no phone number required. No ads or datamining.</string>
|
|
||||||
|
|
||||||
<string name="ftue_auth_carousel_workplace_body">Element is also great for the workplace. It’s trusted by the world’s most secure organisations.</string>
|
|
||||||
|
|
||||||
|
|
||||||
</resources>
|
|
||||||
|
|
@ -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.onboarding.impl
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class OnBoardingPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = OnBoardingPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.canLoginWithQrCode).isFalse()
|
||||||
|
assertThat(initialState.canCreateAccount).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -138,6 +138,7 @@ fun RoomInviteMembersTopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomInviteMembersSearchBar(
|
private fun RoomInviteMembersSearchBar(
|
||||||
query: String,
|
query: String,
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,7 @@ private fun RoomMemberListTopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomMemberSearchBar(
|
private fun RoomMemberSearchBar(
|
||||||
query: String,
|
query: String,
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,6 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi
|
||||||
accompanist_material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" }
|
accompanist_material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" }
|
||||||
accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
|
accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
|
||||||
accompanist_placeholder = { module = "com.google.accompanist:accompanist-placeholder-material", version.ref = "accompanist" }
|
accompanist_placeholder = { module = "com.google.accompanist:accompanist-placeholder-material", version.ref = "accompanist" }
|
||||||
accompanist_pager = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanist" }
|
|
||||||
accompanist_pagerindicator = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist" }
|
|
||||||
accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
|
accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
|
||||||
|
|
||||||
# Libraries
|
# Libraries
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ inline fun <reified NODE : Node> Context.createNode(context: BuildContext, plugi
|
||||||
inline fun <reified NODE : Node> NodeFactoriesBindings.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE {
|
inline fun <reified NODE : Node> NodeFactoriesBindings.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE {
|
||||||
val nodeClass = NODE::class.java
|
val nodeClass = NODE::class.java
|
||||||
val nodeFactoryMap = nodeFactories()
|
val nodeFactoryMap = nodeFactories()
|
||||||
|
// Note to developers: If you got the error below, make sure to build again after
|
||||||
|
// clearing the cache (sometimes several times) to let Dagger generate the NodeFactory.
|
||||||
val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.")
|
val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.")
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ object MimeTypes {
|
||||||
const val Any: String = "*/*"
|
const val Any: String = "*/*"
|
||||||
const val OctetStream = "application/octet-stream"
|
const val OctetStream = "application/octet-stream"
|
||||||
const val Apk = "application/vnd.android.package-archive"
|
const val Apk = "application/vnd.android.package-archive"
|
||||||
|
const val Pdf = "application/pdf"
|
||||||
|
|
||||||
const val Images = "image/*"
|
const val Images = "image/*"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,5 +80,8 @@ val Compound_Gray_300_Dark = Color(0xFF1D1F24)
|
||||||
val Compound_Gray_400_Light = Color(0xFFE1E6EC)
|
val Compound_Gray_400_Light = Color(0xFFE1E6EC)
|
||||||
val Compound_Gray_400_Dark = Color(0xFF26282D)
|
val Compound_Gray_400_Dark = Color(0xFF26282D)
|
||||||
|
|
||||||
val Gray_1400_Light = Color(0xFF1B1D22)
|
val Compound_Gray_800_Light = Color(0xFF818A95)
|
||||||
val Gray_1400_Dark = Color(0xFFEBEEF2)
|
val Compound_Gray_800_Dark = Color(0xFF656C76)
|
||||||
|
|
||||||
|
val Compound_Gray_1400_Light = Color(0xFF1B1D22)
|
||||||
|
val Compound_Gray_1400_Dark = Color(0xFFEBEEF2)
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@ package io.element.android.libraries.designsystem
|
||||||
|
|
||||||
object VectorIcons {
|
object VectorIcons {
|
||||||
val Copy = R.drawable.ic_content_copy
|
val Copy = R.drawable.ic_content_copy
|
||||||
val ArrowForward = R.drawable.ic_content_arrow_forward
|
val Forward = R.drawable.ic_forward
|
||||||
val Delete = R.drawable.ic_baseline_delete_outline_24
|
val Delete = R.drawable.ic_delete
|
||||||
val Reply = R.drawable.ic_baseline_reply_24
|
val Reply = R.drawable.ic_reply
|
||||||
val Edit = R.drawable.ic_baseline_edit_24
|
val Edit = R.drawable.ic_edit
|
||||||
val DoorOpen = R.drawable.ic_door_open_24
|
val DoorOpen = R.drawable.ic_door_open_24
|
||||||
|
val DeveloperMode = R.drawable.ic_developer_mode
|
||||||
|
val ReportContent = R.drawable.ic_report_content
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.designsystem.atomic.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
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.res.painterResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.libraries.designsystem.R
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page for onboarding screens, with content and optional footer.
|
||||||
|
*
|
||||||
|
* Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
|
||||||
|
* @param modifier Classical modifier.
|
||||||
|
* @param footer optional footer.
|
||||||
|
* @param content main content.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OnBoardingPage(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
footer: @Composable () -> Unit = {},
|
||||||
|
content: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// BG
|
||||||
|
Image(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
painter = painterResource(id = R.drawable.onboarding_bg),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
// Content
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
// Footer
|
||||||
|
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
footer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun OnBoardingPageLightPreview() =
|
||||||
|
ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun OnBoardingPageDarkPreview() =
|
||||||
|
ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
OnBoardingPage(
|
||||||
|
content = {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Content",
|
||||||
|
fontSize = 40.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer = {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Footer",
|
||||||
|
fontSize = 40.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ fun BackButton(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
imageVector: ImageVector = Icons.Default.ArrowBack,
|
imageVector: ImageVector = Icons.Default.ArrowBack,
|
||||||
contentDescription: String = stringResource(StringR.string.action_back),
|
contentDescription: String = stringResource(StringR.string.action_back),
|
||||||
enabled: Boolean = true
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,11 @@ import io.element.android.libraries.designsystem.Black_800
|
||||||
import io.element.android.libraries.designsystem.Black_950
|
import io.element.android.libraries.designsystem.Black_950
|
||||||
import io.element.android.libraries.designsystem.Compound_Gray_300_Dark
|
import io.element.android.libraries.designsystem.Compound_Gray_300_Dark
|
||||||
import io.element.android.libraries.designsystem.DarkGrey
|
import io.element.android.libraries.designsystem.DarkGrey
|
||||||
import io.element.android.libraries.designsystem.Gray_1400_Dark
|
import io.element.android.libraries.designsystem.Compound_Gray_1400_Dark
|
||||||
import io.element.android.libraries.designsystem.Gray_300
|
import io.element.android.libraries.designsystem.Gray_300
|
||||||
import io.element.android.libraries.designsystem.Gray_400
|
import io.element.android.libraries.designsystem.Gray_400
|
||||||
import io.element.android.libraries.designsystem.Compound_Gray_400_Dark
|
import io.element.android.libraries.designsystem.Compound_Gray_400_Dark
|
||||||
|
import io.element.android.libraries.designsystem.Compound_Gray_800_Dark
|
||||||
import io.element.android.libraries.designsystem.Gray_450
|
import io.element.android.libraries.designsystem.Gray_450
|
||||||
import io.element.android.libraries.designsystem.SystemGrey5Dark
|
import io.element.android.libraries.designsystem.SystemGrey5Dark
|
||||||
import io.element.android.libraries.designsystem.SystemGrey6Dark
|
import io.element.android.libraries.designsystem.SystemGrey6Dark
|
||||||
|
|
@ -43,8 +44,9 @@ fun elementColorsDark() = ElementColors(
|
||||||
quinary = Gray_450,
|
quinary = Gray_450,
|
||||||
gray300 = Compound_Gray_300_Dark,
|
gray300 = Compound_Gray_300_Dark,
|
||||||
gray400 = Compound_Gray_400_Dark,
|
gray400 = Compound_Gray_400_Dark,
|
||||||
gray1400 = Gray_1400_Dark,
|
gray1400 = Compound_Gray_1400_Dark,
|
||||||
textActionCritical = TextColorCriticalDark,
|
textActionCritical = TextColorCriticalDark,
|
||||||
|
placeholder = Compound_Gray_800_Dark,
|
||||||
isLight = false,
|
isLight = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -59,7 +61,7 @@ val materialColorSchemeDark = darkColorScheme(
|
||||||
// TODO onSecondary = ColorDarkTokens.OnSecondary,
|
// TODO onSecondary = ColorDarkTokens.OnSecondary,
|
||||||
// TODO secondaryContainer = ColorDarkTokens.SecondaryContainer,
|
// TODO secondaryContainer = ColorDarkTokens.SecondaryContainer,
|
||||||
// TODO onSecondaryContainer = ColorDarkTokens.OnSecondaryContainer,
|
// TODO onSecondaryContainer = ColorDarkTokens.OnSecondaryContainer,
|
||||||
tertiary = Color.White,
|
tertiary = Gray_300,
|
||||||
// TODO onTertiary = ColorDarkTokens.OnTertiary,
|
// TODO onTertiary = ColorDarkTokens.OnTertiary,
|
||||||
// TODO tertiaryContainer = ColorDarkTokens.TertiaryContainer,
|
// TODO tertiaryContainer = ColorDarkTokens.TertiaryContainer,
|
||||||
// TODO onTertiaryContainer = ColorDarkTokens.OnTertiaryContainer,
|
// TODO onTertiaryContainer = ColorDarkTokens.OnTertiaryContainer,
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ import io.element.android.libraries.designsystem.Azure
|
||||||
import io.element.android.libraries.designsystem.Black_900
|
import io.element.android.libraries.designsystem.Black_900
|
||||||
import io.element.android.libraries.designsystem.Compound_Gray_300_Light
|
import io.element.android.libraries.designsystem.Compound_Gray_300_Light
|
||||||
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
|
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
|
||||||
|
import io.element.android.libraries.designsystem.Compound_Gray_800_Light
|
||||||
import io.element.android.libraries.designsystem.Gray_100
|
import io.element.android.libraries.designsystem.Gray_100
|
||||||
import io.element.android.libraries.designsystem.Gray_1400_Light
|
import io.element.android.libraries.designsystem.Compound_Gray_1400_Light
|
||||||
import io.element.android.libraries.designsystem.Gray_150
|
import io.element.android.libraries.designsystem.Gray_150
|
||||||
import io.element.android.libraries.designsystem.Gray_200
|
import io.element.android.libraries.designsystem.Gray_200
|
||||||
import io.element.android.libraries.designsystem.Gray_25
|
import io.element.android.libraries.designsystem.Gray_25
|
||||||
|
|
@ -43,8 +44,9 @@ fun elementColorsLight() = ElementColors(
|
||||||
quinary = Gray_50,
|
quinary = Gray_50,
|
||||||
gray300 = Compound_Gray_300_Light,
|
gray300 = Compound_Gray_300_Light,
|
||||||
gray400 = Compound_Gray_400_Light,
|
gray400 = Compound_Gray_400_Light,
|
||||||
gray1400 = Gray_1400_Light,
|
gray1400 = Compound_Gray_1400_Light,
|
||||||
textActionCritical = TextColorCriticalLight,
|
textActionCritical = TextColorCriticalLight,
|
||||||
|
placeholder = Compound_Gray_800_Light,
|
||||||
isLight = true,
|
isLight = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class ElementColors(
|
||||||
gray400: Color,
|
gray400: Color,
|
||||||
gray1400: Color,
|
gray1400: Color,
|
||||||
textActionCritical: Color,
|
textActionCritical: Color,
|
||||||
|
placeholder: Color,
|
||||||
isLight: Boolean
|
isLight: Boolean
|
||||||
) {
|
) {
|
||||||
var messageFromMeBackground by mutableStateOf(messageFromMeBackground)
|
var messageFromMeBackground by mutableStateOf(messageFromMeBackground)
|
||||||
|
|
@ -60,6 +61,9 @@ class ElementColors(
|
||||||
var textActionCritical by mutableStateOf(textActionCritical)
|
var textActionCritical by mutableStateOf(textActionCritical)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var placeholder by mutableStateOf(placeholder)
|
||||||
|
private set
|
||||||
|
|
||||||
var isLight by mutableStateOf(isLight)
|
var isLight by mutableStateOf(isLight)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -73,6 +77,7 @@ class ElementColors(
|
||||||
gray400: Color = this.gray400,
|
gray400: Color = this.gray400,
|
||||||
gray1400: Color = this.gray1400,
|
gray1400: Color = this.gray1400,
|
||||||
textActionCritical: Color = this.textActionCritical,
|
textActionCritical: Color = this.textActionCritical,
|
||||||
|
placeholder: Color = this.placeholder,
|
||||||
isLight: Boolean = this.isLight,
|
isLight: Boolean = this.isLight,
|
||||||
) = ElementColors(
|
) = ElementColors(
|
||||||
messageFromMeBackground = messageFromMeBackground,
|
messageFromMeBackground = messageFromMeBackground,
|
||||||
|
|
@ -84,6 +89,7 @@ class ElementColors(
|
||||||
gray400 = gray400,
|
gray400 = gray400,
|
||||||
gray1400 = gray1400,
|
gray1400 = gray1400,
|
||||||
textActionCritical = textActionCritical,
|
textActionCritical = textActionCritical,
|
||||||
|
placeholder = placeholder,
|
||||||
isLight = isLight,
|
isLight = isLight,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -97,6 +103,7 @@ class ElementColors(
|
||||||
gray400 = other.gray400
|
gray400 = other.gray400
|
||||||
gray1400 = other.gray1400
|
gray1400 = other.gray1400
|
||||||
textActionCritical = other.textActionCritical
|
textActionCritical = other.textActionCritical
|
||||||
|
placeholder = other.placeholder
|
||||||
isLight = other.isLight
|
isLight = other.isLight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,12 @@ import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SearchBarColors
|
||||||
import androidx.compose.material3.SearchBarDefaults
|
import androidx.compose.material3.SearchBarDefaults
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
|
@ -45,6 +46,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
|
import io.element.android.libraries.designsystem.theme.LocalColors
|
||||||
import io.element.android.libraries.ui.strings.R
|
import io.element.android.libraries.ui.strings.R
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -63,6 +65,8 @@ fun <T> SearchBar(
|
||||||
tonalElevation: Dp = SearchBarDefaults.Elevation,
|
tonalElevation: Dp = SearchBarDefaults.Elevation,
|
||||||
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
|
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
inactiveColors: SearchBarColors = ElementSearchBarDefaults.inactiveColors(),
|
||||||
|
activeColors: SearchBarColors = ElementSearchBarDefaults.activeColors(),
|
||||||
contentPrefix: @Composable ColumnScope.() -> Unit = {},
|
contentPrefix: @Composable ColumnScope.() -> Unit = {},
|
||||||
contentSuffix: @Composable ColumnScope.() -> Unit = {},
|
contentSuffix: @Composable ColumnScope.() -> Unit = {},
|
||||||
resultHandler: @Composable ColumnScope.(T) -> Unit = {},
|
resultHandler: @Composable ColumnScope.(T) -> Unit = {},
|
||||||
|
|
@ -83,10 +87,7 @@ fun <T> SearchBar(
|
||||||
modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp),
|
modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp),
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(text = placeHolderTitle)
|
||||||
text = placeHolderTitle,
|
|
||||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
leadingIcon = if (showBackButton && active) {
|
leadingIcon = if (showBackButton && active) {
|
||||||
{ BackButton(onClick = { onActiveChange(false) }) }
|
{ BackButton(onClick = { onActiveChange(false) }) }
|
||||||
|
|
@ -97,7 +98,10 @@ fun <T> SearchBar(
|
||||||
active && query.isNotEmpty() -> {
|
active && query.isNotEmpty() -> {
|
||||||
{
|
{
|
||||||
IconButton(onClick = { onQueryChange("") }) {
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
Icon(Icons.Default.Close, stringResource(R.string.action_clear))
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.action_clear),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +111,7 @@ fun <T> SearchBar(
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
contentDescription = stringResource(R.string.action_search),
|
contentDescription = stringResource(R.string.action_search),
|
||||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +119,7 @@ fun <T> SearchBar(
|
||||||
else -> null
|
else -> null
|
||||||
},
|
},
|
||||||
shape = shape,
|
shape = shape,
|
||||||
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
|
colors = if (active) activeColors else inactiveColors,
|
||||||
tonalElevation = tonalElevation,
|
tonalElevation = tonalElevation,
|
||||||
windowInsets = windowInsets,
|
windowInsets = windowInsets,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
|
@ -147,6 +151,37 @@ fun <T> SearchBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object ElementSearchBarDefaults {
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun inactiveColors() = SearchBarDefaults.colors(
|
||||||
|
containerColor = LocalColors.current.gray300,
|
||||||
|
inputFieldColors = TextFieldDefaults.colors(
|
||||||
|
unfocusedPlaceholderColor = LocalColors.current.placeholder,
|
||||||
|
focusedPlaceholderColor = LocalColors.current.placeholder,
|
||||||
|
unfocusedLeadingIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
focusedLeadingIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedTrailingIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
focusedTrailingIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun activeColors() = SearchBarDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
inputFieldColors = TextFieldDefaults.colors(
|
||||||
|
unfocusedPlaceholderColor = LocalColors.current.placeholder,
|
||||||
|
focusedPlaceholderColor = LocalColors.current.placeholder,
|
||||||
|
unfocusedLeadingIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
focusedLeadingIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedTrailingIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
focusedTrailingIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface SearchBarResultState<in T> {
|
sealed interface SearchBarResultState<in T> {
|
||||||
/** No search results are available yet (e.g. because the user hasn't entered a search term). */
|
/** No search results are available yet (e.g. because the user hasn't entered a search term). */
|
||||||
class NotSearching<T> : SearchBarResultState<T>
|
class NotSearching<T> : SearchBarResultState<T>
|
||||||
|
|
@ -208,17 +243,24 @@ internal fun SearchBarPreviewActiveWithContent() = ElementThemedPreview {
|
||||||
active = true,
|
active = true,
|
||||||
resultState = SearchBarResultState.Results("result!"),
|
resultState = SearchBarResultState.Results("result!"),
|
||||||
contentPrefix = {
|
contentPrefix = {
|
||||||
Text(text = "Content that goes before the search results", modifier = Modifier.background(color = Color.Red).fillMaxWidth())
|
Text(text = "Content that goes before the search results", modifier = Modifier
|
||||||
|
.background(color = Color.Red)
|
||||||
|
.fillMaxWidth())
|
||||||
},
|
},
|
||||||
contentSuffix = {
|
contentSuffix = {
|
||||||
Text(text = "Content that goes after the search results", modifier = Modifier.background(color = Color.Blue).fillMaxWidth())
|
Text(text = "Content that goes after the search results", modifier = Modifier
|
||||||
|
.background(color = Color.Blue)
|
||||||
|
.fillMaxWidth())
|
||||||
},
|
},
|
||||||
resultHandler = {
|
resultHandler = {
|
||||||
Text(text = "Results go here", modifier = Modifier.background(color = Color.Green).fillMaxWidth())
|
Text(text = "Results go here", modifier = Modifier
|
||||||
|
.background(color = Color.Green)
|
||||||
|
.fillMaxWidth())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(
|
private fun ContentToPreview(
|
||||||
query: String = "",
|
query: String = "",
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<!--
|
|
||||||
~ Copyright (c) 2022 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<vector android:height="24dp"
|
|
||||||
android:tint="#000000"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:width="24dp"
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L18,7L6,7v12zM8,9h8v10L8,19L8,9zM15.5,4l-1,-1h-5l-1,1L5,4v2h14L19,4z" />
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<!--
|
|
||||||
~ Copyright (c) 2022 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<vector android:height="24dp"
|
|
||||||
android:tint="#000000"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:width="24dp"
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
|
|
||||||
</vector>
|
|
||||||
10
libraries/designsystem/src/main/res/drawable/ic_delete.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
|
||||||
|
</vector>
|
||||||