Merge remote-tracking branch 'origin/develop' into misc/cjs/create-join-design-feedback
24
.github/renovate.json
vendored
@@ -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
@@ -0,0 +1,29 @@
|
||||
name: Record screenshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
||||
|
||||
jobs:
|
||||
record:
|
||||
name: Record screenshots on branch ${{ inputs.param_branch }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
lfs: 'true'
|
||||
- name: ☕️ Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Record screenshots
|
||||
run: "./.github/workflows/scripts/recordScreenshots.sh"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
|
||||
55
.github/workflows/scripts/recordScreenshots.sh
vendored
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
#
|
||||
# Copyright (c) 2023 New Vector Ltd
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
if [[ -z ${GITHUB_TOKEN} ]]; then
|
||||
echo "Missing GITHUB_TOKEN variable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z ${GITHUB_REPOSITORY} ]]; then
|
||||
echo "Missing GITHUB_REPOSITORY variable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z ${GITHUB_REF_NAME} ]]; then
|
||||
echo "Missing GITHUB_REF_NAME variable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config user.name "ElementBot"
|
||||
git config user.email "benoitm+elementbot@element.io"
|
||||
|
||||
echo "Git status"
|
||||
git status
|
||||
|
||||
echo "Fetching..."
|
||||
git fetch --all
|
||||
|
||||
echo "Checkout origin/$GITHUB_REF_NAME"
|
||||
git checkout "origin/$GITHUB_REF_NAME"
|
||||
|
||||
echo "Record screenshots"
|
||||
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
||||
|
||||
echo "Committing changes"
|
||||
git add -A
|
||||
git commit -m "Update screenshots"
|
||||
|
||||
echo "Pushing changes"
|
||||
git push "https://$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git"
|
||||
echo "Done!"
|
||||
2
.idea/dictionaries/shared.xml
generated
@@ -2,6 +2,8 @@
|
||||
<dictionary name="shared">
|
||||
<words>
|
||||
<w>backstack</w>
|
||||
<w>kover</w>
|
||||
<w>onboarding</w>
|
||||
<w>textfields</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Own your conversations."
|
||||
visible: "Communicate and collaborate securely"
|
||||
timeout: 10_000
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
Redesign the timeline item context menu using M3 bottom sheet
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.media.local.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import java.io.File
|
||||
|
||||
class ParcelFileDescriptorFactory(private val context: Context) {
|
||||
|
||||
fun create(model: Any?) = runCatching {
|
||||
when (model) {
|
||||
is File -> ParcelFileDescriptor.open(model, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
is Uri -> context.contentResolver.openFileDescriptor(model, "r")!!
|
||||
else -> error(RuntimeException("Can't handle this model"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.media.local.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Stable
|
||||
class PdfPage(
|
||||
maxWidth: Int,
|
||||
val pageIndex: Int,
|
||||
private val mutex: Mutex,
|
||||
private val pdfRenderer: PdfRenderer,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) {
|
||||
|
||||
sealed interface State {
|
||||
data class Loading(val width: Int, val height: Int) : State
|
||||
data class Loaded(val bitmap: Bitmap) : State
|
||||
}
|
||||
|
||||
private val renderWidth = maxWidth
|
||||
private val renderHeight: Int
|
||||
private var loadJob: Job? = null
|
||||
|
||||
init {
|
||||
// We are just opening and closing the page to extract data so we can build the Loading state with the correct dimensions.
|
||||
pdfRenderer.openPage(pageIndex).use { page ->
|
||||
renderHeight = (page.height * (renderWidth.toFloat() / page.width)).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private val mutableStateFlow = MutableStateFlow<State>(
|
||||
State.Loading(
|
||||
width = renderWidth,
|
||||
height = renderHeight
|
||||
)
|
||||
)
|
||||
val stateFlow: StateFlow<State> = mutableStateFlow
|
||||
|
||||
fun load() {
|
||||
loadJob = coroutineScope.launch {
|
||||
val bitmap = mutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
pdfRenderer.openPageRenderAndClose(pageIndex, renderWidth, renderHeight)
|
||||
}
|
||||
}
|
||||
mutableStateFlow.value = State.Loaded(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
loadJob?.cancel()
|
||||
when (val loadingState = stateFlow.value) {
|
||||
is State.Loading -> return
|
||||
is State.Loaded -> {
|
||||
loadingState.bitmap.recycle()
|
||||
mutableStateFlow.value = State.Loading(
|
||||
width = renderWidth,
|
||||
height = renderHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PdfRenderer.openPageRenderAndClose(index: Int, bitmapWidth: Int, bitmapHeight: Int): Bitmap {
|
||||
fun createBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(
|
||||
bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
canvas.drawBitmap(bitmap, 0f, 0f, null)
|
||||
return bitmap
|
||||
}
|
||||
return openPage(index).use { page ->
|
||||
createBitmap(bitmapWidth, bitmapHeight).apply {
|
||||
page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.media.local.pdf
|
||||
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class PdfRendererManager(
|
||||
private val parcelFileDescriptor: ParcelFileDescriptor,
|
||||
private val width: Int,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var pdfRenderer: PdfRenderer? = null
|
||||
private val mutablePdfPages = MutableStateFlow<List<PdfPage>>(emptyList())
|
||||
val pdfPages: StateFlow<List<PdfPage>> = mutablePdfPages
|
||||
|
||||
fun open() {
|
||||
coroutineScope.launch {
|
||||
mutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
pdfRenderer = PdfRenderer(parcelFileDescriptor).apply {
|
||||
(0 until pageCount).map { pageIndex ->
|
||||
PdfPage(width, pageIndex, mutex, this, coroutineScope)
|
||||
}.also {
|
||||
mutablePdfPages.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
coroutineScope.launch {
|
||||
mutex.withLock {
|
||||
mutablePdfPages.value.forEach { pdfPage ->
|
||||
pdfPage.close()
|
||||
}
|
||||
pdfRenderer?.close()
|
||||
parcelFileDescriptor.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.media.local.pdf
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import me.saket.telephoto.zoomable.zoomable
|
||||
|
||||
@Composable
|
||||
fun PdfViewer(
|
||||
pdfViewerState: PdfViewerState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier.zoomable(pdfViewerState.zoomableState),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val maxWidthInPx = maxWidth.dpToPx()
|
||||
DisposableEffect(pdfViewerState) {
|
||||
pdfViewerState.openForWidth(maxWidthInPx)
|
||||
onDispose {
|
||||
pdfViewerState.close()
|
||||
}
|
||||
}
|
||||
val pdfPages = pdfViewerState.getPages()
|
||||
PdfPagesView(pdfPages.toImmutableList(), pdfViewerState.lazyListState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfPagesView(
|
||||
pdfPages: ImmutableList<PdfPage>,
|
||||
lazyListState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
state = lazyListState,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically)
|
||||
|
||||
) {
|
||||
items(pdfPages.size) { index ->
|
||||
val pdfPage = pdfPages[index]
|
||||
PdfPageView(pdfPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfPageView(
|
||||
pdfPage: PdfPage,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pdfPageState by pdfPage.stateFlow.collectAsState()
|
||||
DisposableEffect(pdfPage) {
|
||||
pdfPage.load()
|
||||
onDispose {
|
||||
pdfPage.close()
|
||||
}
|
||||
}
|
||||
when (val state = pdfPageState) {
|
||||
is PdfPage.State.Loaded -> {
|
||||
Image(
|
||||
bitmap = state.bitmap.asImageBitmap(),
|
||||
contentDescription = "Page ${pdfPage.pageIndex}",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
is PdfPage.State.Loading -> {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(state.height.pxToDp())
|
||||
.background(color = Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() }
|
||||
|
||||
@Composable
|
||||
private fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.roundToPx() }
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.media.local.pdf
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import me.saket.telephoto.zoomable.ZoomableState
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
|
||||
@Stable
|
||||
class PdfViewerState(
|
||||
private val model: Any?,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val context: Context,
|
||||
val zoomableState: ZoomableState,
|
||||
val lazyListState: LazyListState,
|
||||
) {
|
||||
|
||||
var isLoaded by mutableStateOf(false)
|
||||
private var pdfRendererManager by mutableStateOf<PdfRendererManager?>(null)
|
||||
|
||||
@Composable
|
||||
fun getPages(): List<PdfPage>{
|
||||
return pdfRendererManager?.run {
|
||||
pdfPages.collectAsState().value
|
||||
}?: emptyList()
|
||||
}
|
||||
|
||||
fun openForWidth(maxWidth: Int) {
|
||||
ParcelFileDescriptorFactory(context).create(model)
|
||||
.onSuccess {
|
||||
pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply {
|
||||
open()
|
||||
}
|
||||
isLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
pdfRendererManager?.close()
|
||||
isLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPdfViewerState(
|
||||
model: Any?,
|
||||
zoomableState: ZoomableState = rememberZoomableState(),
|
||||
lazyListState: LazyListState = rememberLazyListState(),
|
||||
context: Context = LocalContext.current,
|
||||
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||
): PdfViewerState {
|
||||
return remember(model) {
|
||||
PdfViewerState(
|
||||
model = model,
|
||||
coroutineScope = coroutineScope,
|
||||
context = context,
|
||||
zoomableState = zoomableState,
|
||||
lazyListState = lazyListState
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Collections
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AttachmentsBottomSheet(
|
||||
state: MessageComposerState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val localView = LocalView.current
|
||||
var isVisible by rememberSaveable { mutableStateOf(state.showAttachmentSourcePicker) }
|
||||
|
||||
BackHandler(enabled = isVisible) {
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(state.showAttachmentSourcePicker) {
|
||||
if (state.showAttachmentSourcePicker) {
|
||||
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
||||
localView.hideKeyboard()
|
||||
isVisible = true
|
||||
} else {
|
||||
isVisible = false
|
||||
}
|
||||
}
|
||||
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
|
||||
LaunchedEffect(isVisible) {
|
||||
if (!isVisible) {
|
||||
state.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
||||
}
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = { isVisible = false }
|
||||
) {
|
||||
AttachmentSourcePickerMenu(eventSink = state.eventSink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
internal fun AttachmentSourcePickerMenu(
|
||||
eventSink: (MessageComposerEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier.padding(bottom = 32.dp)
|
||||
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||
icon = { Icon(Icons.Default.Collections, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||
icon = { Icon(Icons.Default.AttachFile, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||
icon = { Icon(Icons.Default.Videocam, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.util
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.Formatter
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
|
||||
interface FileSizeFormatter {
|
||||
/**
|
||||
* Formats a content size to be in the form of bytes, kilobytes, megabytes, etc.
|
||||
*/
|
||||
fun format(fileSize: Long): String
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class AndroidFileSizeFormatter @Inject constructor(@ApplicationContext private val context: Context) : FileSizeFormatter {
|
||||
override fun format(fileSize: Long): String {
|
||||
return Formatter.formatShortFileSize(context, fileSize)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.timeline
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
|
||||
|
||||
class FakeFileSizeFormatter : FileSizeFormatter {
|
||||
override fun format(fileSize: Long): String {
|
||||
return "$fileSize Bytes"
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,6 @@ dependencies {
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(libs.accompanist.pager)
|
||||
implementation(libs.accompanist.pagerindicator)
|
||||
api(projects.features.onboarding.api)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.onboarding.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Note: this Presenter is ignored regarding code coverage because it cannot reach the coverage threshold.
|
||||
* When this presenter get more code in it, please remove the ignore rule in the kover configuration.
|
||||
*/
|
||||
class OnBoardingPresenter @Inject constructor(
|
||||
) : Presenter<OnBoardingState> {
|
||||
@Composable
|
||||
override fun present(): OnBoardingState {
|
||||
return OnBoardingState(
|
||||
canLoginWithQrCode = OnBoardingConfig.canLoginWithQrCode,
|
||||
canCreateAccount = OnBoardingConfig.canCreateAccount,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.onboarding.impl
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.HorizontalPagerIndicator
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun OnBoardingScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onPageChanged: (Int) -> Unit = {},
|
||||
onSignUp: () -> Unit = {},
|
||||
onSignIn: () -> Unit = {},
|
||||
) {
|
||||
val carrouselData = remember { SplashCarouselDataFactory().create() }
|
||||
val nbOfPages = carrouselData.items.size
|
||||
var key by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
val pagerState = rememberPagerState()
|
||||
LaunchedEffect(key) {
|
||||
launch {
|
||||
delay(3_000)
|
||||
pagerState.animateScrollToPage((pagerState.currentPage + 1) % nbOfPages)
|
||||
// https://stackoverflow.com/questions/73714228/accompanist-pager-animatescrolltopage-doesnt-scroll-to-next-page-correctly
|
||||
key = !key
|
||||
}
|
||||
}
|
||||
LaunchedEffect(pagerState) {
|
||||
// Collect from the pager state a snapshotFlow reading the currentPage
|
||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||
onPageChanged(page)
|
||||
}
|
||||
}
|
||||
HorizontalPager(
|
||||
modifier = Modifier.weight(1f),
|
||||
count = nbOfPages,
|
||||
state = pagerState,
|
||||
) { page ->
|
||||
// Our page content
|
||||
OnBoardingPage(carrouselData.items[page])
|
||||
}
|
||||
HorizontalPagerIndicator(
|
||||
pagerState = pagerState,
|
||||
modifier = Modifier
|
||||
.align(CenterHorizontally)
|
||||
.padding(16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
onSignIn()
|
||||
},
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.align(CenterHorizontally)
|
||||
.testTag(TestTags.onBoardingSignIn)
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.login_splash_submit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnBoardingPage(
|
||||
item: SplashCarouselData.Item,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
/*
|
||||
Image(
|
||||
painterResource(id = item.pageBackground),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
*/
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 16.dp, horizontal = 32.dp)
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = item.image),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(CenterHorizontally)
|
||||
.size(192.dp)
|
||||
.padding(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = item.title),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(CenterHorizontally)
|
||||
.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 24.sp,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = item.body),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(CenterHorizontally),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun OnBoardingScreenLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun OnBoardingScreenDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
OnBoardingScreen()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.onboarding.impl
|
||||
|
||||
data class OnBoardingState(
|
||||
val canLoginWithQrCode: Boolean,
|
||||
val canCreateAccount: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.onboarding.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
||||
open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
|
||||
override val values: Sequence<OnBoardingState>
|
||||
get() = sequenceOf(
|
||||
anOnBoardingState(),
|
||||
anOnBoardingState(canLoginWithQrCode = true),
|
||||
anOnBoardingState(canCreateAccount = true),
|
||||
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
|
||||
)
|
||||
}
|
||||
|
||||
fun anOnBoardingState(
|
||||
canLoginWithQrCode: Boolean = false,
|
||||
canCreateAccount: Boolean = false
|
||||
) = OnBoardingState(
|
||||
canLoginWithQrCode = canLoginWithQrCode,
|
||||
canCreateAccount = canCreateAccount
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.onboarding.impl
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
|
||||
// Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
|
||||
@Composable
|
||||
fun OnBoardingView(
|
||||
state: OnBoardingState,
|
||||
modifier: Modifier = Modifier,
|
||||
onSignInWithQrCode: () -> Unit = {},
|
||||
onSignIn: () -> Unit = {},
|
||||
onCreateAccount: () -> Unit = {},
|
||||
) {
|
||||
OnBoardingPage(
|
||||
modifier = modifier,
|
||||
footer = {
|
||||
OnBoardingButtons(
|
||||
state = state,
|
||||
onSignInWithQrCode = onSignInWithQrCode,
|
||||
onSignIn = onSignIn,
|
||||
onCreateAccount = onCreateAccount,
|
||||
)
|
||||
}
|
||||
) {
|
||||
OnBoardingContent()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnBoardingContent(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = BiasAlignment(
|
||||
horizontalBias = 0f,
|
||||
verticalBias = -0.2f
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.element_logo),
|
||||
contentDescription = null,
|
||||
)
|
||||
Image(
|
||||
modifier = Modifier.padding(top = 14.dp),
|
||||
painter = painterResource(id = R.drawable.element),
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
text = stringResource(id = R.string.screen_onboarding_subtitle),
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
fontSize = 20.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnBoardingButtons(
|
||||
state: OnBoardingState,
|
||||
onSignInWithQrCode: () -> Unit,
|
||||
onSignIn: () -> Unit,
|
||||
onCreateAccount: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (state.canLoginWithQrCode) {
|
||||
Button(
|
||||
onClick = {
|
||||
onSignInWithQrCode()
|
||||
},
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.QrCode, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code))
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
onSignIn()
|
||||
},
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.onBoardingSignIn)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.screen_onboarding_sign_in_manually))
|
||||
}
|
||||
if (state.canCreateAccount) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onCreateAccount()
|
||||
},
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.screen_onboarding_sign_up))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun OnBoardingScreenLightPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
|
||||
ElementPreviewLight { ContentToPreview(state) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun OnBoardingScreenDarkPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: OnBoardingState) {
|
||||
OnBoardingView(state)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.onboarding.impl
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
|
||||
class SplashCarouselDataFactory {
|
||||
fun create(): SplashCarouselData {
|
||||
val lightTheme = true
|
||||
|
||||
fun background(@DrawableRes lightDrawable: Int) =
|
||||
if (lightTheme) lightDrawable else R.drawable.bg_color_background
|
||||
|
||||
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
|
||||
if (lightTheme) lightDrawable else darkDrawable
|
||||
|
||||
return SplashCarouselData(
|
||||
listOf(
|
||||
SplashCarouselData.Item(
|
||||
R.string.ftue_auth_carousel_secure_title,
|
||||
R.string.ftue_auth_carousel_secure_body,
|
||||
hero(
|
||||
R.drawable.ic_splash_conversations,
|
||||
R.drawable.ic_splash_conversations_dark
|
||||
),
|
||||
background(R.drawable.bg_carousel_page_1)
|
||||
),
|
||||
SplashCarouselData.Item(
|
||||
R.string.ftue_auth_carousel_control_title,
|
||||
R.string.ftue_auth_carousel_control_body,
|
||||
hero(R.drawable.ic_splash_control, R.drawable.ic_splash_control_dark),
|
||||
background(R.drawable.bg_carousel_page_2)
|
||||
),
|
||||
SplashCarouselData.Item(
|
||||
R.string.ftue_auth_carousel_encrypted_title,
|
||||
R.string.ftue_auth_carousel_encrypted_body,
|
||||
hero(R.drawable.ic_splash_secure, R.drawable.ic_splash_secure_dark),
|
||||
background(R.drawable.bg_carousel_page_3)
|
||||
),
|
||||
SplashCarouselData.Item(
|
||||
collaborationTitle(),
|
||||
R.string.ftue_auth_carousel_workplace_body,
|
||||
hero(
|
||||
R.drawable.ic_splash_collaboration,
|
||||
R.drawable.ic_splash_collaboration_dark
|
||||
),
|
||||
background(R.drawable.bg_carousel_page_4)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun collaborationTitle(): Int {
|
||||
return when {
|
||||
true -> R.string.cut_the_slack_from_teams
|
||||
else -> R.string.ftue_auth_carousel_workplace_title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="@integer/rtl_mirror_flip"
|
||||
android:endColor="#3372C7DA"
|
||||
android:startColor="#33BBE7CF" />
|
||||
</shape>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="@integer/rtl_mirror_flip"
|
||||
android:endColor="#33B972DA"
|
||||
android:startColor="#3372C7DA" />
|
||||
</shape>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="@integer/rtl_mirror_flip"
|
||||
android:endColor="#330DBD8B"
|
||||
android:startColor="#33B972DA" />
|
||||
</shape>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="@integer/rtl_mirror_flip"
|
||||
android:endColor="#33BBE7CF"
|
||||
android:startColor="#330DBD8B" />
|
||||
</shape>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="?android:colorBackground" />
|
||||
</shape>
|
||||
27
features/onboarding/impl/src/main/res/drawable/element.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="160dp"
|
||||
android:height="34dp"
|
||||
android:viewportWidth="160"
|
||||
android:viewportHeight="34">
|
||||
<path
|
||||
android:pathData="M22.18,23.71H5.07C5.27,25.51 5.92,26.94 7.02,28.01C8.12,29.05 9.56,29.57 11.35,29.57C12.53,29.57 13.6,29.28 14.56,28.71C15.51,28.13 16.19,27.35 16.59,26.36H21.79C21.1,28.65 19.8,30.5 17.89,31.92C16.01,33.31 13.79,34 11.22,34C7.87,34 5.16,32.89 3.08,30.66C1.03,28.43 0,25.61 0,22.2C0,18.87 1.04,16.08 3.12,13.82C5.2,11.56 7.88,10.43 11.18,10.43C14.47,10.43 17.13,11.55 19.15,13.78C21.2,15.97 22.22,18.75 22.22,22.11L22.18,23.71ZM11.18,14.64C9.56,14.64 8.22,15.12 7.15,16.08C6.08,17.03 5.42,18.3 5.16,19.9H17.11C16.88,18.3 16.25,17.03 15.21,16.08C14.17,15.12 12.82,14.64 11.18,14.64Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<path
|
||||
android:pathData="M25.77,26.75V0.93H30.93V26.84C30.93,28 31.56,28.58 32.83,28.58L33.74,28.53V33.44C33.25,33.52 32.73,33.57 32.18,33.57C29.96,33.57 28.33,33 27.29,31.87C26.28,30.75 25.77,29.04 25.77,26.75Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<path
|
||||
android:pathData="M57.75,23.71H40.64C40.84,25.51 41.49,26.94 42.59,28.01C43.69,29.05 45.13,29.57 46.92,29.57C48.11,29.57 49.18,29.28 50.13,28.71C51.08,28.13 51.76,27.35 52.16,26.36H57.36C56.67,28.65 55.37,30.5 53.46,31.92C51.59,33.31 49.36,34 46.79,34C43.44,34 40.73,32.89 38.65,30.66C36.6,28.43 35.57,25.61 35.57,22.2C35.57,18.87 36.61,16.08 38.69,13.82C40.77,11.56 43.46,10.43 46.75,10.43C50.04,10.43 52.7,11.55 54.72,13.78C56.77,15.97 57.8,18.75 57.8,22.11L57.75,23.71ZM46.75,14.64C45.13,14.64 43.79,15.12 42.72,16.08C41.65,17.03 40.99,18.3 40.73,19.9H52.68C52.45,18.3 51.82,17.03 50.78,16.08C49.74,15.12 48.4,14.64 46.75,14.64Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<path
|
||||
android:pathData="M81.01,20.55V33.48H75.86V19.98C75.86,16.57 74.44,14.86 71.61,14.86C70.08,14.86 68.85,15.35 67.93,16.34C67.03,17.32 66.59,18.67 66.59,20.37V33.48H61.43V10.95H66.2V13.95C66.75,12.94 67.58,12.1 68.71,11.43C69.84,10.77 71.24,10.43 72.91,10.43C76.03,10.43 78.28,11.62 79.67,13.99C81.58,11.62 84.12,10.43 87.29,10.43C89.92,10.43 91.94,11.26 93.36,12.91C94.77,14.53 95.48,16.67 95.48,19.33V33.48H90.33V19.98C90.33,16.57 88.91,14.86 86.08,14.86C84.52,14.86 83.28,15.37 82.36,16.38C81.46,17.36 81.01,18.75 81.01,20.55Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<path
|
||||
android:pathData="M121.12,23.71H104.01C104.21,25.51 104.86,26.94 105.96,28.01C107.06,29.05 108.5,29.57 110.29,29.57C111.47,29.57 112.54,29.28 113.5,28.71C114.45,28.13 115.13,27.35 115.53,26.36H120.73C120.04,28.65 118.74,30.5 116.83,31.92C114.96,33.31 112.73,34 110.16,34C106.81,34 104.1,32.89 102.02,30.66C99.97,28.43 98.94,25.61 98.94,22.2C98.94,18.87 99.98,16.08 102.06,13.82C104.14,11.56 106.82,10.43 110.12,10.43C113.41,10.43 116.07,11.55 118.09,13.78C120.14,15.97 121.16,18.75 121.16,22.11L121.12,23.71ZM110.12,14.64C108.5,14.64 107.16,15.12 106.09,16.08C105.02,17.03 104.36,18.3 104.1,19.9H116.05C115.82,18.3 115.19,17.03 114.15,16.08C113.11,15.12 111.76,14.64 110.12,14.64Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<path
|
||||
android:pathData="M129.56,10.95V13.95C130.08,12.97 130.94,12.14 132.12,11.48C133.33,10.78 134.79,10.43 136.5,10.43C139.15,10.43 141.2,11.24 142.65,12.86C144.12,14.48 144.86,16.64 144.86,19.33V33.48H139.7V19.98C139.7,18.39 139.33,17.15 138.57,16.25C137.85,15.32 136.74,14.86 135.24,14.86C133.59,14.86 132.29,15.35 131.34,16.34C130.42,17.32 129.95,18.68 129.95,20.42V33.48H124.8V10.95H129.56Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<path
|
||||
android:pathData="M159.91,28.88V33.35C159.28,33.52 158.38,33.61 157.23,33.61C152.84,33.61 150.64,31.4 150.64,26.97V15.08H147.22V10.95H150.64V5.1H155.8V10.95H160V15.08H155.8V26.45C155.8,28.21 156.63,29.1 158.31,29.1L159.91,28.88Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,26 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="50dp"
|
||||
android:height="49dp"
|
||||
android:viewportWidth="50"
|
||||
android:viewportHeight="49">
|
||||
<path
|
||||
android:pathData="M24.8,48.608C38.199,48.608 49.061,37.726 49.061,24.304C49.061,10.881 38.199,0 24.8,0C11.401,0 0.54,10.881 0.54,24.304C0.54,37.726 11.401,48.608 24.8,48.608Z"
|
||||
android:fillColor="#0DBD8B"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M20.365,11.324C20.365,10.343 21.16,9.548 22.142,9.548C28.793,9.548 34.185,14.938 34.185,21.587C34.185,22.568 33.389,23.363 32.408,23.363C31.426,23.363 30.631,22.568 30.631,21.587C30.631,16.9 26.83,13.1 22.142,13.1C21.16,13.1 20.365,12.305 20.365,11.324Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M37.753,19.81C38.735,19.81 39.53,20.606 39.53,21.587C39.53,28.236 34.138,33.626 27.487,33.626C26.506,33.626 25.71,32.831 25.71,31.85C25.71,30.869 26.506,30.073 27.487,30.073C32.176,30.073 35.976,26.274 35.976,21.587C35.976,20.606 36.772,19.81 37.753,19.81Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M29.264,37.283C29.264,38.264 28.468,39.06 27.487,39.06C20.836,39.06 15.444,33.669 15.444,27.02C15.444,26.039 16.24,25.244 17.221,25.244C18.202,25.244 18.998,26.039 18.998,27.02C18.998,31.708 22.799,35.507 27.487,35.507C28.468,35.507 29.264,36.302 29.264,37.283Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M11.847,28.797C10.866,28.797 10.071,28.002 10.071,27.021C10.071,20.372 15.462,14.981 22.113,14.981C23.095,14.981 23.89,15.777 23.89,16.758C23.89,17.739 23.095,18.534 22.113,18.534C17.425,18.534 13.624,22.333 13.624,27.021C13.624,28.002 12.829,28.797 11.847,28.797Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<!-- File to remove once the screen will be updated -->
|
||||
|
||||
<string name="cut_the_slack_from_teams" translatable="false">Cut the slack from teams.</string>
|
||||
|
||||
<string name="login_splash_submit">Get started</string>
|
||||
|
||||
<string name="ftue_auth_carousel_secure_title">Own your conversations.</string>
|
||||
<string name="ftue_auth_carousel_control_title">You\'re in control.</string>
|
||||
<string name="ftue_auth_carousel_encrypted_title">Secure messaging.</string>
|
||||
<string name="ftue_auth_carousel_workplace_title">Messaging for your team.</string>
|
||||
|
||||
<string name="ftue_auth_carousel_secure_body">Secure and independent communication that gives you the same level of privacy as a face-to-face conversation in your own home.</string>
|
||||
<string name="ftue_auth_carousel_control_body">Choose where your conversations are kept, giving you control and independence. Connected via Matrix.</string>
|
||||
<string name="ftue_auth_carousel_encrypted_body">End-to-end encrypted and no phone number required. No ads or datamining.</string>
|
||||
|
||||
<string name="ftue_auth_carousel_workplace_body">Element is also great for the workplace. It’s trusted by the world’s most secure organisations.</string>
|
||||
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.onboarding.impl
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class OnBoardingPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = OnBoardingPresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.canLoginWithQrCode).isFalse()
|
||||
assertThat(initialState.canCreateAccount).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,7 @@ fun RoomInviteMembersTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomInviteMembersSearchBar(
|
||||
query: String,
|
||||
|
||||
@@ -230,6 +230,7 @@ private fun RoomMemberListTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomMemberSearchBar(
|
||||
query: String,
|
||||
|
||||
@@ -96,8 +96,6 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi
|
||||
accompanist_material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" }
|
||||
accompanist_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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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/*"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.atomic.pages
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.libraries.designsystem.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
/**
|
||||
* Page for onboarding screens, with content and optional footer.
|
||||
*
|
||||
* Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
|
||||
* @param modifier Classical modifier.
|
||||
* @param footer optional footer.
|
||||
* @param content main content.
|
||||
*/
|
||||
@Composable
|
||||
fun OnBoardingPage(
|
||||
modifier: Modifier = Modifier,
|
||||
footer: @Composable () -> Unit = {},
|
||||
content: @Composable () -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
// BG
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
painter = painterResource(id = R.drawable.onboarding_bg),
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.padding(vertical = 16.dp),
|
||||
) {
|
||||
// Content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
// Footer
|
||||
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
footer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun OnBoardingPageLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun OnBoardingPageDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
OnBoardingPage(
|
||||
content = {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Content",
|
||||
fontSize = 40.sp
|
||||
)
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Footer",
|
||||
fontSize = 40.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ fun BackButton(
|
||||
modifier: Modifier = Modifier,
|
||||
imageVector: ImageVector = Icons.Default.ArrowBack,
|
||||
contentDescription: String = stringResource(StringR.string.action_back),
|
||||
enabled: Boolean = true
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier,
|
||||
|
||||
@@ -25,10 +25,11 @@ import io.element.android.libraries.designsystem.Black_800
|
||||
import io.element.android.libraries.designsystem.Black_950
|
||||
import io.element.android.libraries.designsystem.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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="24dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L18,7L6,7v12zM8,9h8v10L8,19L8,9zM15.5,4l-1,-1h-5l-1,1L5,4v2h14L19,4z" />
|
||||
</vector>
|
||||
@@ -1,26 +0,0 @@
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="24dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
|
||||
</vector>
|
||||
10
libraries/designsystem/src/main/res/drawable/ic_delete.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
|
||||
</vector>
|
||||