Merge remote-tracking branch 'origin/develop' into misc/cjs/create-join-design-feedback

This commit is contained in:
Chris Smith
2023-06-02 16:24:46 +01:00
288 changed files with 2578 additions and 1274 deletions

24
.github/renovate.json vendored
View File

@@ -1,18 +1,28 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"$schema" : "https://docs.renovatebot.com/renovate-schema.json",
"extends" : [
"config:base"
],
"labels": ["dependencies"],
"ignoreDeps": ["string:app_name"],
"packageRules": [
"labels" : [
"dependencies"
],
"ignoreDeps" : [
"string:app_name"
],
"packageRules" : [
{
"matchPackagePatterns": [
"matchPackagePatterns" : [
"^org.jetbrains.kotlin",
"^com.google.devtools.ksp",
"^androidx.compose.compiler"
],
"groupName": "kotlin"
"groupName" : "kotlin"
},
{
"matchPackageNames" : [
"org.jetbrains.kotlinx.kover"
],
"enabled" : false
}
]
}

29
.github/workflows/recordScreenshots.yml vendored Normal file
View 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 }}

View 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!"

View File

@@ -2,6 +2,8 @@
<dictionary name="shared">
<words>
<w>backstack</w>
<w>kover</w>
<w>onboarding</w>
<w>textfields</w>
</words>
</dictionary>

View File

@@ -1,6 +1,6 @@
appId: ${APP_ID}
---
- tapOn: "Get started"
- tapOn: "Sign in manually"
- runFlow: ../assertions/assertLoginDisplayed.yaml
- takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml

View File

@@ -1,5 +1,5 @@
appId: ${APP_ID}
---
- extendedWaitUntil:
visible: "Own your conversations."
visible: "Communicate and collaborate securely"
timeout: 10_000

View File

@@ -142,15 +142,6 @@ android {
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 {
buildConfig = true
}

View File

@@ -93,10 +93,10 @@ class RootFlowNode @AssistedInject constructor(
if (isLoggedIn) {
tryToRestoreLatestSession(
onSuccess = { switchToLoggedInFlow(it) },
onFailure = { switchToLogoutFlow() }
onFailure = { switchToNotLoggedInFlow() }
)
} else {
switchToLogoutFlow()
switchToNotLoggedInFlow()
}
}
.launchIn(lifecycleScope)
@@ -106,7 +106,7 @@ class RootFlowNode @AssistedInject constructor(
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
}
private fun switchToLogoutFlow() {
private fun switchToNotLoggedInFlow() {
matrixClientsHolder.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow)
}

View File

@@ -203,6 +203,8 @@ koverMerged {
includes += "*Presenter"
excludes += "*Fake*Presenter"
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
// Too small presenter, cannot reach the threshold.
excludes += "io.element.android.features.onboarding.impl.OnBoardingPresenter"
}
bound {
minValue = 90

1
changelog.d/483.feature Normal file
View File

@@ -0,0 +1 @@
Redesign the timeline item context menu using M3 bottom sheet

View File

@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
@@ -43,6 +44,7 @@ import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchUserBar(
query: String,

View File

@@ -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.media.viewer.MediaViewerNode
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.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode
@@ -134,6 +135,16 @@ class MessagesFlowNode @AssistedInject constructor(
)
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
}
}

View File

@@ -118,6 +118,8 @@ class MessagesPresenter @Inject constructor(
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
TimelineItemAction.Developer -> notImplementedYet()
TimelineItemAction.ReportContent -> notImplementedYet()
}
}

View File

@@ -16,7 +16,6 @@
package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
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.width
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.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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
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.attachments.Attachment
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.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
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.theme.components.Icon
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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -91,7 +82,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MessagesView(
state: MessagesState,
@@ -103,26 +94,11 @@ fun MessagesView(
modifier: Modifier = Modifier,
) {
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()
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
BackHandler(enabled = bottomSheetState.isVisible) {
coroutineScope.launch {
bottomSheetState.hide()
}
}
val snackbarHostState = remember { SnackbarHostState() }
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
if (snackbarMessageText != null) {
@@ -150,78 +126,57 @@ fun MessagesView(
Timber.v("OnMessageLongClicked= ${event.id}")
localView.hideKeyboard()
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
isMessageActionsBottomSheetVisible = true
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
isMessageActionsBottomSheetVisible = false
state.eventSink(MessagesEvents.HandleAction(action, event))
}
LaunchedEffect(composerState.showAttachmentSourcePicker) {
if (composerState.showAttachmentSourcePicker) {
// 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()
}
fun onDismissActionListBottomSheet() {
isMessageActionsBottomSheetVisible = false
}
// 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(
state = state.actionListState,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = ::onActionSelected
)
}
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(
state = state.actionListState,
isVisible = isMessageActionsBottomSheetVisible,
onDismiss = ::onDismissActionListBottomSheet,
onActionSelected = ::onActionSelected
)
}
@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
@Composable
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =

View File

@@ -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.TimelineItemStateContent
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
class ActionListPresenter @Inject constructor(
private val buildMeta: BuildMeta,
) : Presenter<ActionListState> {
@Composable
override fun present(): ActionListState {
@@ -60,21 +63,30 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
when (timelineItem.content) {
is TimelineItemRedactedContent,
is TimelineItemStateContent -> {
// TODO Add Share action (also) here, and developer options
emptyList()
}
else -> {
mutableListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
).also {
if (timelineItem.isMine) {
it.add(TimelineItemAction.Edit)
it.add(TimelineItemAction.Redact)
buildList {
add(TimelineItemAction.Copy)
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}
}
}
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())
}

View File

@@ -16,52 +16,81 @@
package io.element.android.features.messages.impl.actionlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
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.PreviewParameter
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
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.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.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.ModalBottomSheetLayout
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterialApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActionListView(
state: ActionListState,
modalBottomSheetState: ModalBottomSheetState,
isVisible: Boolean,
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(modalBottomSheetState) {
snapshotFlow { modalBottomSheetState.currentValue }
.filter { it == ModalBottomSheetValue.Hidden }
.collect {
state.eventSink(ActionListEvents.Clear)
}
LaunchedEffect(isVisible) {
if (!isVisible) {
state.eventSink(ActionListEvents.Clear)
}
}
fun onItemActionClicked(
@@ -69,24 +98,22 @@ fun ActionListView(
targetItem: TimelineItem.Event
) {
onActionSelected(itemAction, targetItem)
coroutineScope.launch {
modalBottomSheetState.hide()
}
}
ModalBottomSheetLayout(
modifier = modifier,
sheetState = modalBottomSheetState,
sheetContent = {
if (isVisible) {
ModalBottomSheet(
onDismissRequest = onDismiss
) {
SheetContent(
state = state,
onActionClicked = ::onItemActionClicked,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
modifier = modifier
.padding(bottom = 32.dp)
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
// .imePadding()
)
}
)
}
}
@OptIn(ExperimentalMaterialApi::class)
@@ -108,6 +135,19 @@ private fun SheetContent(
LazyColumn(
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 = actions,
) { 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
@Composable
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
@@ -145,14 +320,7 @@ fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) s
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
ElementPreviewDark { ContentToPreview(state) }
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun ContentToPreview(state: ActionListState) {
ActionListView(
state = state,
modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
),
onActionSelected = { _, _ -> }
)
SheetContent(state = state)
}

View File

@@ -26,9 +26,11 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward)
object Forward : TimelineItemAction("Forward", VectorIcons.Forward)
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
object Developer : TimelineItemAction("Developer", VectorIcons.DeveloperMode)
object ReportContent : TimelineItemAction("Report content", VectorIcons.ReportContent, destructive = true)
}

View File

@@ -32,15 +32,20 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
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.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.utils.OnLifecycleEvent
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
import me.saket.telephoto.zoomable.rememberZoomableState
@@ -53,14 +58,24 @@ fun LocalMediaView(
mimeType: String? = localMedia?.mimeType,
onReady: () -> Unit = {},
) {
val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
)
when {
MimeTypes.isImage(mimeType) -> MediaImageView(
mimeType.isMimeTypeImage() -> MediaImageView(
localMedia = localMedia,
zoomableState = zoomableState,
onReady = onReady,
modifier = modifier
)
mimeType.isMimeTypeVideo() -> MediaVideoView(
localMedia = localMedia,
onReady = onReady,
modifier = modifier
)
MimeTypes.isVideo(mimeType) -> MediaVideoView(
mimeType == MimeTypes.Pdf -> MediaPDFView(
localMedia = localMedia,
zoomableState = zoomableState,
onReady = onReady,
modifier = modifier
)
@@ -71,6 +86,7 @@ fun LocalMediaView(
@Composable
private fun MediaImageView(
localMedia: LocalMedia?,
zoomableState: ZoomableState,
onReady: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -81,9 +97,6 @@ private fun MediaImageView(
contentDescription = null,
)
} else {
val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 3f)
)
val zoomableImageState = rememberZoomableImageState(zoomableState)
LaunchedEffect(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)
}

View File

@@ -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"))
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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() }

View File

@@ -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
)
}
}

View File

@@ -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)) },
)
}
}

View File

@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@@ -46,21 +47,25 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.UpdateText(text))
}
TextComposer(
onSendMessage = ::sendMessage,
fullscreen = state.isFullScreen,
onFullscreenToggle = ::onFullscreenToggle,
composerMode = state.mode,
onCloseSpecialMode = ::onCloseSpecialMode,
onComposerTextChange = ::onComposerTextChange,
onAddAttachment = {
state.eventSink(MessageComposerEvents.AddAttachment)
},
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(),
isInDarkMode = !ElementTheme.colors.isLight,
modifier = modifier
)
Box {
AttachmentsBottomSheet(state = state)
TextComposer(
onSendMessage = ::sendMessage,
fullscreen = state.isFullScreen,
onFullscreenToggle = ::onFullscreenToggle,
composerMode = state.mode,
onCloseSpecialMode = ::onCloseSpecialMode,
onComposerTextChange = ::onComposerTextChange,
onAddAttachment = {
state.eventSink(MessageComposerEvents.AddAttachment)
},
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(),
isInDarkMode = !ElementTheme.colors.isLight,
modifier = modifier
)
}
}
@Preview

View File

@@ -18,21 +18,24 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.style.TextOverflow
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.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -47,7 +50,6 @@ fun TimelineItemFileView(
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
@@ -57,16 +59,23 @@ fun TimelineItemFileView(
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Attachment,
contentDescription = "OpenFile"
imageVector = Icons.Outlined.Attachment,
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,
)
}
}

View File

@@ -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.TimelineItemUnknownContent
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.libraries.matrix.api.timeline.item.event.EmoteMessageType
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 javax.inject.Inject
class TimelineItemContentMessageFactory @Inject constructor() {
class TimelineItemContentMessageFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter
) {
fun create(content: MessageContent): TimelineItemEventContent {
return when (val messageType = content.type) {
@@ -74,7 +77,9 @@ class TimelineItemContentMessageFactory @Inject constructor() {
thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source,
mimeType = messageType.info?.mimetype,
size = messageType.info?.size,
formattedFileSize = messageType.info?.size?.let {
fileSizeFormatter.format(it)
},
)
is NoticeMessageType -> TimelineItemNoticeContent(
body = messageType.body,

View File

@@ -22,8 +22,17 @@ data class TimelineItemFileContent(
val body: String,
val fileSource: MediaSource,
val thumbnailSource: MediaSource?,
val size: Long?,
val formattedFileSize: String?,
val mimeType: String?,
) : TimelineItemEventContent {
override val type: String = "TimelineItemFileContent"
private val fileExtension = body.substringAfterLast('.', "").uppercase()
val fileExtensionAndSize = buildString {
append(fileExtension)
if (formattedFileSize != null) {
append(' ')
append("($formattedFileSize)")
}
}
}

View File

@@ -25,7 +25,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
get() = sequenceOf(
aTimelineItemFileContent("A 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 = ""),
fileSource = MediaSource(url = ""),
mimeType = MimeTypes.OctetStream,
size = 100
formattedFileSize = "100kB"
)

View File

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

View File

@@ -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.media.FakeLocalMediaFactory
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.featureflag.test.FakeFeatureFlagService
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(
matrixRoom: MatrixRoom = FakeMatrixRoom()
): MessagesPresenter {
@@ -148,7 +176,20 @@ class MessagesPresenterTest {
timelineItemsFactory = aTimelineItemsFactory(),
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(
room = matrixRoom,
composerPresenter = messageComposerPresenter,

View File

@@ -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.TimelineItemRedactedContent
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.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -42,7 +44,7 @@ import org.junit.Test
class ActionListPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = ActionListPresenter()
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -53,7 +55,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for message from me redacted`() = runTest {
val presenter = ActionListPresenter()
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -67,6 +69,8 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
)
)
)
@@ -75,9 +79,10 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for message from others redacted`() = runTest {
val presenter = ActionListPresenter()
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -91,6 +96,8 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
)
)
)
@@ -101,7 +108,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for others message`() = runTest {
val presenter = ActionListPresenter()
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -121,6 +128,8 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Developer,
TimelineItemAction.ReportContent,
)
)
)
@@ -131,7 +140,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for my message`() = runTest {
val presenter = ActionListPresenter()
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -150,8 +159,41 @@ class ActionListPresenterTest {
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
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,
)
)
@@ -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(
isMine: Boolean,
content: TimelineItemEventContent,

View File

@@ -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.TimelineItemVirtualFactory
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.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@@ -42,7 +43,7 @@ internal fun aTimelineItemsFactory(): TimelineItemsFactory {
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(),
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter()),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
utdFactory = TimelineItemContentUTDFactory(),

View File

@@ -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"
}
}

View File

@@ -40,8 +40,6 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(libs.accompanist.pager)
implementation(libs.accompanist.pagerindicator)
api(projects.features.onboarding.api)
ksp(libs.showkase.processor)

View File

@@ -16,16 +16,7 @@
package io.element.android.features.onboarding.impl
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
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
)
object OnBoardingConfig {
const val canLoginWithQrCode = false
const val canCreateAccount = false
}

View File

@@ -32,6 +32,7 @@ import io.element.android.libraries.di.AppScope
class OnBoardingNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: OnBoardingPresenter,
) : Node(
buildContext = buildContext,
plugins = plugins
@@ -47,10 +48,11 @@ class OnBoardingNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
OnBoardingScreen(
val state = presenter.present()
OnBoardingView(
state = state,
modifier = modifier,
onSignIn = this::onSignIn,
onSignUp = this::onSignUp
onSignIn = ::onSignIn,
)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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()
}

View File

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

View File

@@ -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
)

View File

@@ -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)
}

View File

@@ -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
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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. Its trusted by the worlds most secure organisations.</string>
</resources>

View File

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

View File

@@ -138,6 +138,7 @@ fun RoomInviteMembersTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomInviteMembersSearchBar(
query: String,

View File

@@ -230,6 +230,7 @@ private fun RoomMemberListTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomMemberSearchBar(
query: String,

View File

@@ -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_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", 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" }
# Libraries

View File

@@ -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 {
val nodeClass = NODE::class.java
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}.")
@Suppress("UNCHECKED_CAST")

View File

@@ -23,6 +23,7 @@ object MimeTypes {
const val Any: String = "*/*"
const val OctetStream = "application/octet-stream"
const val Apk = "application/vnd.android.package-archive"
const val Pdf = "application/pdf"
const val Images = "image/*"

View File

@@ -80,5 +80,8 @@ val Compound_Gray_300_Dark = Color(0xFF1D1F24)
val Compound_Gray_400_Light = Color(0xFFE1E6EC)
val Compound_Gray_400_Dark = Color(0xFF26282D)
val Gray_1400_Light = Color(0xFF1B1D22)
val Gray_1400_Dark = Color(0xFFEBEEF2)
val Compound_Gray_800_Light = Color(0xFF818A95)
val Compound_Gray_800_Dark = Color(0xFF656C76)
val Compound_Gray_1400_Light = Color(0xFF1B1D22)
val Compound_Gray_1400_Dark = Color(0xFFEBEEF2)

View File

@@ -18,9 +18,11 @@ package io.element.android.libraries.designsystem
object VectorIcons {
val Copy = R.drawable.ic_content_copy
val ArrowForward = R.drawable.ic_content_arrow_forward
val Delete = R.drawable.ic_baseline_delete_outline_24
val Reply = R.drawable.ic_baseline_reply_24
val Edit = R.drawable.ic_baseline_edit_24
val Forward = R.drawable.ic_forward
val Delete = R.drawable.ic_delete
val Reply = R.drawable.ic_reply
val Edit = R.drawable.ic_edit
val DoorOpen = R.drawable.ic_door_open_24
val DeveloperMode = R.drawable.ic_developer_mode
val ReportContent = R.drawable.ic_report_content
}

View File

@@ -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
)
}
}
)
}

View File

@@ -36,7 +36,7 @@ fun BackButton(
modifier: Modifier = Modifier,
imageVector: ImageVector = Icons.Default.ArrowBack,
contentDescription: String = stringResource(StringR.string.action_back),
enabled: Boolean = true
enabled: Boolean = true,
) {
IconButton(
modifier = modifier,

View File

@@ -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.Compound_Gray_300_Dark
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_400
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.SystemGrey5Dark
import io.element.android.libraries.designsystem.SystemGrey6Dark
@@ -43,8 +44,9 @@ fun elementColorsDark() = ElementColors(
quinary = Gray_450,
gray300 = Compound_Gray_300_Dark,
gray400 = Compound_Gray_400_Dark,
gray1400 = Gray_1400_Dark,
gray1400 = Compound_Gray_1400_Dark,
textActionCritical = TextColorCriticalDark,
placeholder = Compound_Gray_800_Dark,
isLight = false,
)
@@ -59,7 +61,7 @@ val materialColorSchemeDark = darkColorScheme(
// TODO onSecondary = ColorDarkTokens.OnSecondary,
// TODO secondaryContainer = ColorDarkTokens.SecondaryContainer,
// TODO onSecondaryContainer = ColorDarkTokens.OnSecondaryContainer,
tertiary = Color.White,
tertiary = Gray_300,
// TODO onTertiary = ColorDarkTokens.OnTertiary,
// TODO tertiaryContainer = ColorDarkTokens.TertiaryContainer,
// TODO onTertiaryContainer = ColorDarkTokens.OnTertiaryContainer,

View File

@@ -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.Compound_Gray_300_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_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_200
import io.element.android.libraries.designsystem.Gray_25
@@ -43,8 +44,9 @@ fun elementColorsLight() = ElementColors(
quinary = Gray_50,
gray300 = Compound_Gray_300_Light,
gray400 = Compound_Gray_400_Light,
gray1400 = Gray_1400_Light,
gray1400 = Compound_Gray_1400_Light,
textActionCritical = TextColorCriticalLight,
placeholder = Compound_Gray_800_Light,
isLight = true,
)

View File

@@ -33,6 +33,7 @@ class ElementColors(
gray400: Color,
gray1400: Color,
textActionCritical: Color,
placeholder: Color,
isLight: Boolean
) {
var messageFromMeBackground by mutableStateOf(messageFromMeBackground)
@@ -60,6 +61,9 @@ class ElementColors(
var textActionCritical by mutableStateOf(textActionCritical)
private set
var placeholder by mutableStateOf(placeholder)
private set
var isLight by mutableStateOf(isLight)
private set
@@ -73,6 +77,7 @@ class ElementColors(
gray400: Color = this.gray400,
gray1400: Color = this.gray1400,
textActionCritical: Color = this.textActionCritical,
placeholder: Color = this.placeholder,
isLight: Boolean = this.isLight,
) = ElementColors(
messageFromMeBackground = messageFromMeBackground,
@@ -84,6 +89,7 @@ class ElementColors(
gray400 = gray400,
gray1400 = gray1400,
textActionCritical = textActionCritical,
placeholder = placeholder,
isLight = isLight,
)
@@ -97,6 +103,7 @@ class ElementColors(
gray400 = other.gray400
gray1400 = other.gray1400
textActionCritical = other.textActionCritical
placeholder = other.placeholder
isLight = other.isLight
}
}

View File

@@ -29,11 +29,12 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarColors
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
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.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.ui.strings.R
@OptIn(ExperimentalMaterial3Api::class)
@@ -63,6 +65,8 @@ fun <T> SearchBar(
tonalElevation: Dp = SearchBarDefaults.Elevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
inactiveColors: SearchBarColors = ElementSearchBarDefaults.inactiveColors(),
activeColors: SearchBarColors = ElementSearchBarDefaults.activeColors(),
contentPrefix: @Composable ColumnScope.() -> Unit = {},
contentSuffix: @Composable ColumnScope.() -> Unit = {},
resultHandler: @Composable ColumnScope.(T) -> Unit = {},
@@ -83,10 +87,7 @@ fun <T> SearchBar(
modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp),
enabled = enabled,
placeholder = {
Text(
text = placeHolderTitle,
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
Text(text = placeHolderTitle)
},
leadingIcon = if (showBackButton && active) {
{ BackButton(onClick = { onActiveChange(false) }) }
@@ -111,7 +112,7 @@ fun <T> SearchBar(
Icon(
imageVector = Icons.Default.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,
)
}
}
@@ -119,7 +120,7 @@ fun <T> SearchBar(
else -> null
},
shape = shape,
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
colors = if (active) activeColors else inactiveColors,
tonalElevation = tonalElevation,
windowInsets = windowInsets,
interactionSource = interactionSource,
@@ -151,6 +152,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> {
/** No search results are available yet (e.g. because the user hasn't entered a search term). */
class NotSearching<T> : SearchBarResultState<T>
@@ -212,17 +244,24 @@ internal fun SearchBarPreviewActiveWithContent() = ElementThemedPreview {
active = true,
resultState = SearchBarResultState.Results("result!"),
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 = {
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 = {
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
private fun ContentToPreview(
query: String = "",

View File

@@ -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>

View File

@@ -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>

View 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>

Some files were not shown because too many files have changed in this diff Show More