Merge branch 'develop' into feature/fga/media_viewer_actions

This commit is contained in:
ganfra
2023-06-07 16:31:28 +02:00
155 changed files with 1143 additions and 1314 deletions

View File

@@ -17,7 +17,11 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository == 'vector-im/element-x-android' }}
steps:
- uses: actions/checkout@v3
- name: ⏬ Checkout with LFS
uses: actions/checkout@v3
with:
lfs: 'true'
- name: Use JDK 17
uses: actions/setup-java@v3
with:
@@ -41,3 +45,27 @@ jobs:
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
# Gradle dependency analysis using https://github.com/autonomousapps/dependency-analysis-android-gradle-plugin
dependency-analysis:
name: Dependency analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.4.2
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
- name: Upload dependency analysis
if: always()
uses: actions/upload-artifact@v3
with:
name: dependency-analysis
path: build/reports/dependency-check-report.html

View File

@@ -66,35 +66,3 @@ jobs:
DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
# Fallback for forks
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gradle dependency analysis using https://github.com/autonomousapps/dependency-analysis-android-gradle-plugin
dependency-analysis:
name: Dependency analysis
runs-on: ubuntu-latest
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('dep-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('dep-develop-{0}', github.sha) || format('dep-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Use JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.4.2
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
- name: Upload dependency analysis
if: always()
uses: actions/upload-artifact@v3
with:
name: dependency-analysis
path: build/reports/dependency-check-report.html

View File

@@ -5,8 +5,6 @@ appId: ${APP_ID}
- inputText: ${ROOM_NAME.substring(0, 3)}
- takeScreenshot: build/maestro/400-SearchRoom
- tapOn: ${ROOM_NAME}
# Close keyboard
- hideKeyboard
# Back from timeline
- back
# Close keyboard

View File

@@ -4,10 +4,8 @@ appId: ${APP_ID}
# TODO Create a room on a new account
- tapOn: ${ROOM_NAME}
- takeScreenshot: build/maestro/500-Timeline
- tapOn: "Message"
- tapOn: "Message"
- inputText: "Hello world!"
- tapOn: "Toggle full screen mode"
- tapOn: "Toggle full screen mode"
- tapOn: "Send"
- hideKeyboard
- back

View File

@@ -216,7 +216,8 @@ dependencies {
implementation(libs.coil)
implementation(platform(libs.network.okhttp.bom))
implementation("com.squareup.okhttp3:logging-interceptor")
implementation(libs.network.okhttp.logging)
implementation(libs.serialization.json)
implementation(libs.dagger)
kapt(libs.dagger.compiler)

View File

@@ -158,10 +158,13 @@ koverMerged {
"anvil.hint.merge.*",
"anvil.module.*",
"com.airbnb.android.showkase*",
"io.element.android.libraries.designsystem.showkase.*",
"*_Factory",
"*_Factory$*",
"*_Module",
"*_Module$*",
"*Module_Provides*",
"Dagger*Component*",
"*ComposableSingletons$*",
"*_AssistedFactory_Impl*",
"*BuildConfig",
@@ -175,6 +178,25 @@ koverMerged {
)
)
}
annotations {
excludes.addAll(
listOf(
"*Preview",
)
)
}
projects {
excludes.addAll(
listOf(
":anvilannotations",
":anvilcodegen",
":samples:minimal",
":tests:testutils",
)
)
}
}
// Run ./gradlew koverMergedVerify to check the rules.

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

@@ -0,0 +1 @@
New UI for composer and editing messages

View File

@@ -94,7 +94,7 @@ internal fun DefaultInviteSummaryRow(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(16.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.Top
) {

View File

@@ -14,19 +14,14 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.attachments.preview
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.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@@ -39,6 +34,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -151,9 +147,8 @@ private fun AttachmentsPreviewBottomActions(
onSendClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
ButtonRowMolecule(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onCancelClicked) {
Text(stringResource(id = StringsR.string.action_cancel))

View File

@@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.textcomposer.TextComposer
@Composable
@@ -52,17 +51,14 @@ fun MessageComposerView(
TextComposer(
onSendMessage = ::sendMessage,
fullscreen = state.isFullScreen,
onFullscreenToggle = ::onFullscreenToggle,
composerMode = state.mode,
onCloseSpecialMode = ::onCloseSpecialMode,
onResetComposerMode = ::onCloseSpecialMode,
onComposerTextChange = ::onComposerTextChange,
onAddAttachment = {
state.eventSink(MessageComposerEvents.AddAttachment)
},
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(),
isInDarkMode = !ElementTheme.colors.isLight,
modifier = modifier
)
}

View File

@@ -17,7 +17,6 @@
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
@@ -40,6 +39,7 @@ 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.molecules.ButtonColumnMolecule
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
@@ -117,12 +117,7 @@ private fun OnBoardingButtons(
onCreateAccount: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth(),
horizontalAlignment = CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
ButtonColumnMolecule(modifier = modifier) {
if (state.canLoginWithQrCode) {
Button(
onClick = {

View File

@@ -261,7 +261,7 @@ internal fun TopicSection(
Text(
roomTopic.topic,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.tertiary
)
}

View File

@@ -266,6 +266,7 @@ private fun LabelledReadOnlyField(
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.primary,
text = title,
)

View File

@@ -18,7 +18,6 @@ package io.element.android.features.roomlist.impl
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.ExperimentalLayoutApi
@@ -218,32 +217,34 @@ fun RoomListContent(
if (state.invitesState != InvitesState.NoInvites) {
item {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.clickable(role = Role.Button, onClick = onInvitesClicked)
.heightIn(min = 48.dp),
Box(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(StringR.string.action_invites_list),
fontSize = 14.sp,
style = noFontPadding,
)
if (state.invitesState == InvitesState.NewInvites) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.roomListUnreadIndicator())
Row(
modifier = Modifier
.clickable(role = Role.Button, onClick = onInvitesClicked)
.padding(horizontal = 16.dp)
.align(Alignment.CenterEnd)
.heightIn(min = 48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(StringR.string.action_invites_list),
fontSize = 14.sp,
style = noFontPadding,
)
}
Spacer(Modifier.width(16.dp))
if (state.invitesState == InvitesState.NewInvites) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.roomListUnreadIndicator())
)
}
}
}
}
}

View File

@@ -17,19 +17,15 @@
package io.element.android.features.verifysession.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -38,25 +34,21 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.platform.LocalConfiguration
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.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
@@ -82,21 +74,18 @@ fun VerifySelfSessionView(
val buttonsVisible by remember(verificationFlowStep) {
derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed }
}
Surface {
Column(modifier = modifier.systemBarsPadding()) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 20.dp)
) {
HeaderContent(verificationFlowStep = verificationFlowStep)
Content(modifier = Modifier.weight(1f), flowState = verificationFlowStep)
}
HeaderFooterPage(
modifier = modifier,
header = {
HeaderContent(verificationFlowStep = verificationFlowStep)
},
footer = {
if (buttonsVisible) {
BottomMenu(screenState = state, goBack = ::goBackAndCancelIfNeeded)
}
}
) {
Content(flowState = verificationFlowStep)
}
}
@@ -121,58 +110,22 @@ internal fun HeaderContent(verificationFlowStep: FlowStep, modifier: Modifier =
FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_subtitle
}
Column(modifier) {
Spacer(Modifier.height(80.dp))
Box(
modifier = Modifier
.size(width = 70.dp, height = 70.dp)
.align(Alignment.CenterHorizontally)
.background(
color = LocalColors.current.quinary,
shape = RoundedCornerShape(14.dp)
)
) {
Spacer(modifier = Modifier.height(68.dp))
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(width = 48.dp, height = 48.dp),
tint = MaterialTheme.colorScheme.secondary,
resourceId = iconResourceId,
contentDescription = "",
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = titleTextId),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center,
style = ElementTextStyles.Bold.title2,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(id = subtitleTextId),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = ElementTextStyles.Regular.subheadline,
color = MaterialTheme.colorScheme.secondary,
)
}
IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 60.dp),
iconResourceId = iconResourceId,
title = stringResource(id = titleTextId),
subTitle = stringResource(id = subtitleTextId)
)
}
@Composable
internal fun Content(flowState: FlowStep, modifier: Modifier = Modifier) {
Column(modifier, verticalArrangement = Arrangement.Center) {
Spacer(Modifier.shrinkableHeight(min = 20.dp, max = 56.dp))
Column(modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
when (flowState) {
FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting()
is FlowStep.Verifying -> ContentVerifying(flowState)
}
Spacer(Modifier.shrinkableHeight(min = 20.dp, max = 56.dp))
}
}
@@ -259,11 +212,8 @@ internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
else -> goBack
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 20.dp)
) {
ButtonWithProgress(
text = positiveButtonTitle?.let { stringResource(it) },
@@ -272,7 +222,6 @@ internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
onClick = { positiveButtonEvent?.let { eventSink(it) } }
)
if (negativeButtonTitle != null) {
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = negativeButtonCallback,
@@ -281,7 +230,6 @@ internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
Text(stringResource(negativeButtonTitle), fontSize = 16.sp)
}
}
Spacer(Modifier.height(40.dp))
}
}
@@ -302,15 +250,3 @@ private fun ContentToPreview(state: VerifySelfSessionState) {
goBack = {},
)
}
private fun Modifier.shrinkableHeight(
min: Dp,
max: Dp,
minScreenHeight: Int = 720
): Modifier = composed {
if (LocalConfiguration.current.screenHeightDp >= minScreenHeight) {
then(Modifier.height(max))
} else {
then(Modifier.height(min))
}
}

View File

@@ -104,6 +104,8 @@ squareup_seismic = "com.squareup:seismic:1.0.3"
# network
network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:4.11.0"
network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" }
network_okhttp = { module = "com.squareup.okhttp3:okhttp" }
network_retrofit = "com.squareup.retrofit2:retrofit:2.9.0"
network_retrofit_converter_serialization = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0"
@@ -181,7 +183,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ktlint = "org.jlleitschuh.gradle.ktlint:11.3.2"
ktlint = "org.jlleitschuh.gradle.ktlint:11.4.0"
dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" }
dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" }
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" }

View File

@@ -0,0 +1,69 @@
/*
* 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.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.designsystem.theme.components.TextButton
@Composable
fun ButtonColumnMolecule(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Column(
modifier = modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
content()
}
}
@Preview
@Composable
internal fun ButtonColumnMoleculeLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun ButtonColumnMoleculeDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
ButtonColumnMolecule {
Button(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text(text = "Button")
}
TextButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text(text = "TextButton")
}
}
}

View File

@@ -0,0 +1,64 @@
/*
* 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.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
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
import io.element.android.libraries.designsystem.theme.components.TextButton
@Composable
fun ButtonRowMolecule(
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
content()
}
}
@Preview
@Composable
internal fun ButtonRowMoleculeLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun ButtonRowMoleculeDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
ButtonRowMolecule {
TextButton(onClick = { }) {
Text("Button 1")
}
TextButton(onClick = { }) {
Text("Button 2")
}
}
}

View File

@@ -0,0 +1,105 @@
/*
* 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.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.ElementTextStyles
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.LocalColors
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun IconTitleSubtitleMolecule(
iconResourceId: Int,
title: String,
subTitle: String,
modifier: Modifier = Modifier,
) {
Column(modifier) {
Box(
modifier = Modifier
.size(width = 70.dp, height = 70.dp)
.align(Alignment.CenterHorizontally)
.background(
color = LocalColors.current.quinary,
shape = RoundedCornerShape(14.dp)
)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(width = 48.dp, height = 48.dp),
tint = MaterialTheme.colorScheme.secondary,
resourceId = iconResourceId,
contentDescription = "",
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
modifier = Modifier
.fillMaxWidth(),
textAlign = TextAlign.Center,
style = ElementTextStyles.Bold.title2,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
text = subTitle,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = ElementTextStyles.Regular.subheadline,
color = MaterialTheme.colorScheme.secondary,
)
}
}
@Preview
@Composable
internal fun IconTitleSubtitleMoleculeLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun IconTitleSubtitleMoleculeDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
IconTitleSubtitleMolecule(
iconResourceId = R.drawable.ic_edit,
title = "Title",
subTitle = "Sub iitle",
)
}

View File

@@ -0,0 +1,119 @@
/*
* 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.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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
/**
* @param modifier Classical modifier.
* @param header optional header.
* @param footer optional footer.
* @param content main content.
*/
@Composable
fun HeaderFooterPage(
modifier: Modifier = Modifier,
header: @Composable () -> Unit = {},
footer: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.padding(all = 20.dp),
) {
// Header
header()
// Content
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
content()
}
// Footer
footer()
}
}
@Preview
@Composable
internal fun HeaderFooterPageLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun HeaderFooterPageDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
HeaderFooterPage(
content = {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Content",
fontSize = 40.sp
)
}
},
header = {
Box(
Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Header",
fontSize = 40.sp
)
}
},
footer = {
Box(
Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Footer",
fontSize = 40.sp
)
}
}
)
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -47,6 +48,7 @@ fun LabelledTextField(
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.primary,
text = label
)

View File

@@ -72,13 +72,13 @@ private fun ProgressDialogContent(
modifier = Modifier.padding(top = 38.dp, bottom = 32.dp, start = 40.dp, end = 40.dp)
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.primary
)
if (!text.isNullOrBlank()) {
Spacer(modifier = Modifier.height(22.dp))
Text(
text = text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.primary,
)
}
}

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
@@ -48,7 +49,7 @@ fun PreferenceCategory(
}
content()
if (showDivider) {
PreferenceDivider()
Divider()
}
}
}

View File

@@ -1,46 +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.libraries.designsystem.components.preferences
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Divider
@Composable
fun PreferenceDivider(modifier: Modifier = Modifier) {
Divider(modifier, thickness = 0.5.dp)
}
@Preview(group = PreviewGroup.Dividers)
@Composable
internal fun PreferenceDividerPreview() {
ElementThemedPreview {
Box(Modifier.padding(vertical = 10.dp), contentAlignment = Alignment.Center) {
PreferenceDivider()
}
}
}

View File

@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.button.BackButton
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -131,13 +132,13 @@ private fun ContentToPreview() {
subtitle = "Some other text",
icon = Icons.Default.BugReport,
)
PreferenceDivider()
Divider()
PreferenceSwitch(
title = "Switch",
icon = Icons.Default.Announcement,
isChecked = true,
)
PreferenceDivider()
Divider()
PreferenceSlide(
title = "Slide",
summary = "Summary",

View File

@@ -85,7 +85,7 @@ fun PreferenceText(
}
if (subtitle != null) {
Text(
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
text = subtitle,
color = tintColor ?: MaterialTheme.colorScheme.tertiary,
)

View File

@@ -46,6 +46,7 @@ fun elementColorsDark() = ElementColors(
gray400 = Compound_Gray_400_Dark,
gray1400 = Compound_Gray_1400_Dark,
textActionCritical = TextColorCriticalDark,
accentColor = Color(0xFF0DBD8B),
placeholder = Compound_Gray_800_Dark,
isLight = false,
)

View File

@@ -46,6 +46,7 @@ fun elementColorsLight() = ElementColors(
gray400 = Compound_Gray_400_Light,
gray1400 = Compound_Gray_1400_Light,
textActionCritical = TextColorCriticalLight,
accentColor = Color(0xFF0DBD8B),
placeholder = Compound_Gray_800_Light,
isLight = true,
)

View File

@@ -33,6 +33,7 @@ class ElementColors(
gray400: Color,
gray1400: Color,
textActionCritical: Color,
accentColor: Color,
placeholder: Color,
isLight: Boolean
) {
@@ -61,6 +62,9 @@ class ElementColors(
var textActionCritical by mutableStateOf(textActionCritical)
private set
var accentColor by mutableStateOf(accentColor)
private set
var placeholder by mutableStateOf(placeholder)
private set
@@ -77,6 +81,7 @@ class ElementColors(
gray400: Color = this.gray400,
gray1400: Color = this.gray1400,
textActionCritical: Color = this.textActionCritical,
accentColor: Color = this.accentColor,
placeholder: Color = this.placeholder,
isLight: Boolean = this.isLight,
) = ElementColors(
@@ -89,6 +94,7 @@ class ElementColors(
gray400 = gray400,
gray1400 = gray1400,
textActionCritical = textActionCritical,
accentColor = accentColor,
placeholder = placeholder,
isLight = isLight,
)
@@ -103,6 +109,7 @@ class ElementColors(
gray400 = other.gray400
gray1400 = other.gray1400
textActionCritical = other.textActionCritical
accentColor = other.accentColor
placeholder = other.placeholder
isLight = other.isLight
}

View File

@@ -32,7 +32,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
@Composable
fun Divider(
modifier: Modifier = Modifier,
thickness: Dp = DividerDefaults.Thickness,
thickness: Dp = ElementDividerDefaults.thickness,
color: Color = DividerDefaults.color,
) {
androidx.compose.material3.Divider(
@@ -42,6 +42,10 @@ fun Divider(
)
}
object ElementDividerDefaults {
val thickness = 0.5.dp
}
@Preview(group = PreviewGroup.Dividers)
@Composable
internal fun DividerPreview() = ElementThemedPreview {

View File

@@ -39,6 +39,7 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -95,6 +96,53 @@ fun OutlinedTextField(
)
}
@Composable
fun OutlinedTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = OutlinedTextFieldDefaults.shape,
colors: TextFieldColors = OutlinedTextFieldDefaults.colors()
) {
androidx.compose.material3.OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
supportingText = supportingText,
isError = isError,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = maxLines,
interactionSource = interactionSource,
shape = shape,
colors = colors,
)
}
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.onTabOrEnterKeyFocusNext(focusManager: FocusManager): Modifier = onPreviewKeyEvent { event ->
if (event.key == Key.Tab || event.key == Key.Enter) {

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -96,6 +97,53 @@ fun TextField(
)
}
@Composable
fun TextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors()
) {
androidx.compose.material3.TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
supportingText = supportingText,
isError = isError,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = maxLines,
interactionSource = interactionSource,
shape = shape,
colors = colors,
)
}
@Preview(group = PreviewGroup.TextFields)
@Composable
internal fun TextFieldLightPreview() =

View File

@@ -31,9 +31,8 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(platform(libs.network.okhttp.bom))
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:logging-interceptor")
implementation(libs.network.okhttp)
implementation(libs.network.okhttp.logging)
implementation(libs.network.retrofit)
implementation(libs.network.retrofit.converter.serialization)
implementation(libs.serialization.json)

View File

@@ -23,41 +23,36 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
@Module
@ContributesTo(AppScope::class)
object NetworkModule {
@Provides
@JvmStatic
fun providesHttpLoggingInterceptor(buildMeta: BuildMeta): HttpLoggingInterceptor {
val loggingLevel = if (buildMeta.isDebuggable) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.BASIC
}
val logger = FormattedJsonHttpLogger(loggingLevel)
val interceptor = HttpLoggingInterceptor(logger)
interceptor.level = loggingLevel
return interceptor
}
@Provides
@SingleIn(AppScope::class)
fun providesOkHttpClient(
httpLoggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient {
return OkHttpClient.Builder()
// workaround for #4669
.protocols(listOf(Protocol.HTTP_1_1))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.addInterceptor(httpLoggingInterceptor)
.build()
buildMeta: BuildMeta,
): OkHttpClient = OkHttpClient.Builder().apply {
connectTimeout(30, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(60, TimeUnit.SECONDS)
if (buildMeta.isDebuggable) addInterceptor(providesHttpLoggingInterceptor())
}.build()
@Provides
@SingleIn(AppScope::class)
fun providesJson(): Json = Json {
ignoreUnknownKeys = true
}
}
private fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor {
val loggingLevel = HttpLoggingInterceptor.Level.BODY
val logger = FormattedJsonHttpLogger(loggingLevel)
val interceptor = HttpLoggingInterceptor(logger)
interceptor.level = loggingLevel
return interceptor
}

View File

@@ -17,23 +17,21 @@
package io.element.android.libraries.network
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Lazy
import io.element.android.libraries.core.uri.ensureTrailingSlash
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Provider
class RetrofitFactory @Inject constructor(
private val okHttpClient: Lazy<OkHttpClient>,
private val okHttpClient: Provider<OkHttpClient>,
private val json: Provider<Json>,
) {
fun create(baseUrl: String): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.addConverterFactory(Json.asConverterFactory(contentType))
.callFactory { request -> okHttpClient.get().newCall(request) }
.build()
}
fun create(baseUrl: String): Retrofit = Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.addConverterFactory(json.get().asConverterFactory("application/json".toMediaType()))
.callFactory(okHttpClient.get())
.build()
}

View File

@@ -1,56 +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.libraries.textcomposer
import android.net.Uri
import android.text.Editable
import android.widget.EditText
import android.widget.ImageButton
// Imported from Element Android
interface MessageComposerView {
companion object {
const val MAX_LINES_WHEN_COLLAPSED = 10
}
val text: Editable?
val formattedText: String?
val editText: EditText
val emojiButton: ImageButton?
val sendButton: ImageButton
val attachmentButton: ImageButton
var callback: Callback?
fun setTextIfDifferent(text: CharSequence?): Boolean
fun renderComposerMode(mode: MessageComposerMode)
}
interface Callback {
// From ComposerEditText.Callback
fun onRichContentSelected(contentUri: Uri): Boolean
// From ComposerEditText.Callback
fun onTextChanged(text: CharSequence)
fun onCloseRelatedMessage()
fun onSendMessage(text: CharSequence)
fun onAddAttachment()
fun onExpandOrCompactChange()
fun onFullScreenModeChanged()
}

View File

@@ -1,552 +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.libraries.textcomposer
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.text.Editable
import android.text.TextWatcher
import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.google.android.material.shape.MaterialShapeDrawable
import io.element.android.libraries.androidutils.ui.DimensionConverter
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.androidutils.ui.showKeyboard
import io.element.android.libraries.textcomposer.databinding.ComposerRichTextLayoutBinding
import io.element.android.libraries.textcomposer.databinding.ViewRichTextMenuButtonBinding
import io.element.android.libraries.textcomposer.tools.setTextIfDifferent
import io.element.android.wysiwyg.EditorEditText
import io.element.android.wysiwyg.view.models.InlineFormat
import uniffi.wysiwyg_composer.ActionState
import uniffi.wysiwyg_composer.ComposerAction
import io.element.android.libraries.resources.R as ElementR
import io.element.android.libraries.ui.strings.R as StringR
// Imported from Element Android
class RichTextComposerLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr), MessageComposerView {
private val views: ComposerRichTextLayoutBinding
override var callback: Callback? = null
// There is no need to persist these values since they're always updated by the parent fragment
private var isFullScreen = false
private var hasRelatedMessage = false
private var composerMode: MessageComposerMode? = null
var isTextFormattingEnabled = true
set(value) {
if (field == value) return
syncEditTexts()
field = value
updateTextFieldBorder(isFullScreen)
updateEditTextVisibility()
updateFullScreenButtonVisibility()
// If formatting is no longer enabled and it's in full screen, minimise the editor
if (!value && isFullScreen) {
callback?.onFullScreenModeChanged()
}
}
override val text: Editable?
get() = editText.text
override val formattedText: String?
get() = (editText as? EditorEditText)?.getHtmlOutput()
override val editText: EditText
get() = if (isTextFormattingEnabled) {
views.richTextComposerEditText
} else {
views.plainTextComposerEditText
}
override val emojiButton: ImageButton?
get() = null
override val sendButton: ImageButton
get() = views.sendButton
override val attachmentButton: ImageButton
get() = views.attachmentButton
// Border of the EditText
private val borderShapeDrawable: MaterialShapeDrawable by lazy {
MaterialShapeDrawable().apply {
val typedData = TypedValue()
val lineColor = context.theme.obtainStyledAttributes(
typedData.data,
intArrayOf(ElementR.attr.vctr_content_quaternary)
)
.getColor(0, 0)
strokeColor = ColorStateList.valueOf(lineColor)
strokeWidth = 1 * resources.displayMetrics.scaledDensity
fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
val cornerSize =
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line)
setCornerSize(cornerSize.toFloat())
}
}
private val dimensionConverter = DimensionConverter(resources)
fun setFullScreen(isFullScreen: Boolean, animated: Boolean, manageKeyboard: Boolean) {
if (!animated && views.composerLayout.layoutParams != null) {
views.composerLayout.updateLayoutParams<ViewGroup.LayoutParams> {
height =
if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT
}
}
editText.updateLayoutParams<ViewGroup.LayoutParams> {
height = if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT
}
updateTextFieldBorder(isFullScreen)
updateEditTextVisibility()
updateEditTextFullScreenState(views.richTextComposerEditText, isFullScreen)
updateEditTextFullScreenState(views.plainTextComposerEditText, isFullScreen)
views.composerFullScreenButton.setImageResource(
if (isFullScreen) R.drawable.ic_composer_collapse else R.drawable.ic_composer_full_screen
)
views.bottomSheetHandle.isVisible = false // EAx: always gone, we do not have a bottom sheet here. // isFullScreen
if (manageKeyboard) {
if (isFullScreen) {
editText.showKeyboard(true)
} else {
editText.hideKeyboard()
}
}
this.isFullScreen = isFullScreen
}
fun notifyIsBeingDragged(percentage: Float) {
// Calculate a new shape for the border according to the position in screen
val isSingleLine = editText.lineCount == 1
val cornerSize = if (!isSingleLine || hasRelatedMessage) {
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded)
.toFloat()
} else {
val multilineCornerSize =
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded)
val singleLineCornerSize =
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line)
val diff = singleLineCornerSize - multilineCornerSize
multilineCornerSize + diff * (1 - percentage)
}
if (cornerSize != borderShapeDrawable.bottomLeftCornerResolvedSize) {
borderShapeDrawable.setCornerSize(cornerSize)
}
// Change maxLines while dragging, this should improve the smoothness of animations
val maxLines = if (percentage > 0.25f) {
Int.MAX_VALUE
} else {
MessageComposerView.MAX_LINES_WHEN_COLLAPSED
}
views.richTextComposerEditText.maxLines = maxLines
views.plainTextComposerEditText.maxLines = maxLines
views.bottomSheetHandle.isVisible = false // EAx: always gone, we do not have a bottom sheet here.
}
init {
inflate(context, R.layout.composer_rich_text_layout, this)
views = ComposerRichTextLayoutBinding.bind(this)
// Workaround to avoid cut-off text caused by padding in scrolled TextView (there is no clipToPadding).
// In TextView, clipTop = padding, but also clipTop -= shadowRadius. So if we set the shadowRadius to padding, they cancel each other
views.richTextComposerEditText.setShadowLayer(
views.richTextComposerEditText.paddingBottom.toFloat(),
0f,
0f,
0
)
views.plainTextComposerEditText.setShadowLayer(
views.richTextComposerEditText.paddingBottom.toFloat(),
0f,
0f,
0
)
renderComposerMode(MessageComposerMode.Normal(null))
views.richTextComposerEditText.addTextChangedListener(
TextChangeListener(
{ callback?.onTextChanged(it) },
{ updateTextFieldBorder(isFullScreen) })
)
views.plainTextComposerEditText.addTextChangedListener(
TextChangeListener(
{ callback?.onTextChanged(it) },
{ updateTextFieldBorder(isFullScreen) })
)
disallowParentInterceptTouchEvent(views.richTextComposerEditText)
disallowParentInterceptTouchEvent(views.plainTextComposerEditText)
views.composerModeCloseView.setOnClickListener {
callback?.onCloseRelatedMessage()
}
views.sendButton.setOnClickListener {
val textMessage =
views.richTextComposerEditText.getMarkdown() // text?.toSpannable() ?: ""
callback?.onSendMessage(textMessage)
}
views.attachmentButton.setOnClickListener {
callback?.onAddAttachment()
}
views.composerFullScreenButton.apply {
updateFullScreenButtonVisibility()
setOnClickListener {
callback?.onFullScreenModeChanged()
}
}
views.composerEditTextOuterBorder.background = borderShapeDrawable
setupRichTextMenu()
updateTextFieldBorder(isFullScreen)
}
private fun setupRichTextMenu() {
addRichTextMenuItem(
R.drawable.ic_composer_bold,
R.string.rich_text_editor_format_bold,
ComposerAction.BOLD
) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Bold)
}
addRichTextMenuItem(
R.drawable.ic_composer_italic,
R.string.rich_text_editor_format_italic,
ComposerAction.ITALIC
) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Italic)
}
addRichTextMenuItem(
R.drawable.ic_composer_underlined,
R.string.rich_text_editor_format_underline,
ComposerAction.UNDERLINE
) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Underline)
}
addRichTextMenuItem(
R.drawable.ic_composer_strikethrough,
R.string.rich_text_editor_format_strikethrough,
ComposerAction.STRIKE_THROUGH
) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough)
}
}
@SuppressLint("ClickableViewAccessibility")
private fun disallowParentInterceptTouchEvent(view: View) {
view.setOnTouchListener { v, event ->
if (v.hasFocus()) {
v.parent?.requestDisallowInterceptTouchEvent(true)
val action = event.actionMasked
if (action == MotionEvent.ACTION_SCROLL) {
v.parent?.requestDisallowInterceptTouchEvent(false)
return@setOnTouchListener true
}
}
false
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
views.richTextComposerEditText.actionStatesChangedListener =
EditorEditText.OnActionStatesChangedListener { state ->
for (action in state.keys) {
updateMenuStateFor(action, state)
}
}
updateEditTextVisibility()
}
private fun updateEditTextVisibility() {
views.richTextComposerEditText.isVisible = isTextFormattingEnabled
views.richTextMenu.isVisible = isTextFormattingEnabled
views.plainTextComposerEditText.isVisible = !isTextFormattingEnabled
// The layouts for formatted text mode and plain text mode are different, so we need to update the constraints
val dpToPx = { dp: Int -> dimensionConverter.dpToPx(dp) }
ConstraintSet().apply {
clone(views.composerLayoutContent)
clear(R.id.composerEditTextOuterBorder, ConstraintSet.TOP)
clear(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM)
clear(R.id.composerEditTextOuterBorder, ConstraintSet.START)
clear(R.id.composerEditTextOuterBorder, ConstraintSet.END)
if (isTextFormattingEnabled) {
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.TOP,
R.id.composerLayoutContent,
ConstraintSet.TOP,
dpToPx(8)
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.BOTTOM,
R.id.sendButton,
ConstraintSet.TOP,
0
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.START,
R.id.composerLayoutContent,
ConstraintSet.START,
dpToPx(12)
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.END,
R.id.composerLayoutContent,
ConstraintSet.END,
dpToPx(12)
)
} else {
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.TOP,
R.id.composerLayoutContent,
ConstraintSet.TOP,
dpToPx(10)
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.BOTTOM,
R.id.composerLayoutContent,
ConstraintSet.BOTTOM,
dpToPx(10)
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.START,
R.id.attachmentButton,
ConstraintSet.END,
0
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.END,
R.id.sendButton,
ConstraintSet.START,
0
)
}
applyTo(views.composerLayoutContent)
}
}
private fun updateFullScreenButtonVisibility() {
val isLargeScreenDevice =
resources.configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
// There's no point in having full screen in landscape since there's almost no vertical space
views.composerFullScreenButton.isInvisible =
!isTextFormattingEnabled || (isLandscape && !isLargeScreenDevice)
}
/**
* Updates the non-active input with the contents of the active input.
*/
private fun syncEditTexts() =
if (isTextFormattingEnabled) {
views.plainTextComposerEditText.setText(views.richTextComposerEditText.getMarkdown())
} else {
views.richTextComposerEditText.setMarkdown(views.plainTextComposerEditText.text.toString())
}
private fun addRichTextMenuItem(
@DrawableRes iconId: Int,
@StringRes description: Int,
action: ComposerAction,
onClick: () -> Unit
) {
val inflater = LayoutInflater.from(context)
val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true)
button.root.tag = action
with(button.root) {
contentDescription = resources.getString(description)
setImageResource(iconId)
setOnClickListener {
onClick()
}
}
}
private fun updateMenuStateFor(
action: ComposerAction,
menuState: Map<ComposerAction, ActionState>
) {
val button = findViewWithTag<ImageButton>(action) ?: return
val stateForAction = menuState[action]
button.isEnabled = stateForAction != ActionState.DISABLED
button.isSelected = stateForAction == ActionState.REVERSED
}
fun estimateCollapsedHeight(): Int {
val editText = this.editText
val originalLines = editText.maxLines
val originalParamsHeight = editText.layoutParams.height
editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED
editText.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.UNSPECIFIED,
)
val result = measuredHeight
editText.layoutParams.height = originalParamsHeight
editText.maxLines = originalLines
return result
}
private fun updateTextFieldBorder(isFullScreen: Boolean) {
val isMultiline =
editText.editableText.lines().count() > 1 || isFullScreen || hasRelatedMessage
val cornerSize = if (isMultiline) {
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded)
} else {
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line)
}.toFloat()
borderShapeDrawable.setCornerSize(cornerSize)
}
private fun replaceFormattedContent(text: CharSequence) {
views.richTextComposerEditText.setHtml(text.toString())
updateTextFieldBorder(isFullScreen)
}
override fun setTextIfDifferent(text: CharSequence?): Boolean {
val result = editText.setTextIfDifferent(text)
updateTextFieldBorder(isFullScreen)
return result
}
private fun updateEditTextFullScreenState(editText: EditText, isFullScreen: Boolean) {
if (isFullScreen) {
editText.maxLines = Int.MAX_VALUE
} else {
editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED
}
}
override fun renderComposerMode(mode: MessageComposerMode) {
if (this.composerMode == mode) return
this.composerMode = mode
if (mode is MessageComposerMode.Special) {
views.composerModeGroup.isVisible = true
replaceFormattedContent(mode.defaultContent)
hasRelatedMessage = true
editText.showKeyboard(andRequestFocus = true)
} else {
views.composerModeGroup.isGone = true
(mode as? MessageComposerMode.Normal)?.content?.let { text ->
// TODO un-comment once we update to a version of the lib > 0.8.0
/*
if (isTextFormattingEnabled) {
replaceFormattedContent(text)
} else {
views.plainTextComposerEditText.setText(text)
}
*/
views.plainTextComposerEditText.setText(text)
}
views.sendButton.contentDescription = resources.getString(StringR.string.action_send)
hasRelatedMessage = false
}
views.sendButton.apply {
if (mode is MessageComposerMode.Edit) {
contentDescription = resources.getString(StringR.string.action_save)
setImageResource(R.drawable.ic_composer_rich_text_save)
} else {
contentDescription = resources.getString(StringR.string.action_send)
setImageResource(R.drawable.ic_rich_composer_send)
}
}
updateTextFieldBorder(isFullScreen)
when (mode) {
is MessageComposerMode.Edit -> {
views.composerModeTitleView.setText(R.string.editing)
views.composerModeIconView.setImageResource(R.drawable.ic_composer_rich_text_editor_edit)
}
is MessageComposerMode.Quote -> {
views.composerModeTitleView.setText(R.string.quoting)
views.composerModeIconView.setImageResource(R.drawable.ic_quote)
}
is MessageComposerMode.Reply -> {
val userName = mode.senderName
views.composerModeTitleView.text =
resources.getString(R.string.replying_to, userName)
views.composerModeIconView.setImageResource(R.drawable.ic_reply)
}
else -> Unit
}
}
private class TextChangeListener(
private val onTextChanged: (s: Editable) -> Unit,
private val onExpandedChanged: (isExpanded: Boolean) -> Unit,
) : TextWatcher {
private var previousTextWasExpanded = false
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable) {
onTextChanged.invoke(s)
val isExpanded = s.lines().count() > 1
if (previousTextWasExpanded != isExpanded) {
onExpandedChanged(isExpanded)
}
previousTextWasExpanded = isExpanded
}
}
}

View File

@@ -16,140 +16,275 @@
package io.element.android.libraries.textcomposer
import android.graphics.Color
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.VisualTransformation
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 androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TextComposer(
fullscreen: Boolean,
composerText: String?,
composerMode: MessageComposerMode,
composerCanSendMessage: Boolean,
isInDarkMode: Boolean,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = FocusRequester(),
onSendMessage: (String) -> Unit = {},
onFullscreenToggle: () -> Unit = {},
onCloseSpecialMode: () -> Unit = {},
onResetComposerMode: () -> Unit = {},
onComposerTextChange: (CharSequence) -> Unit = {},
onAddAttachment:() -> Unit = {},
) {
if (LocalInspectionMode.current) {
FakeComposer(modifier)
} else {
val focusRequester = FocusRequester()
AndroidView(
modifier = modifier.focusRequester(focusRequester),
factory = { context ->
RichTextComposerLayout(context).apply {
// Sets up listeners for View -> Compose communication
this.callback = object : Callback {
override fun onRichContentSelected(contentUri: Uri): Boolean {
return false
}
override fun onTextChanged(text: CharSequence) {
onComposerTextChange(text)
}
override fun onCloseRelatedMessage() {
onCloseSpecialMode()
}
override fun onSendMessage(text: CharSequence) {
// text contains markdown.
onSendMessage(text.toString())
}
override fun onAddAttachment() {
onAddAttachment()
}
override fun onExpandOrCompactChange() {
}
override fun onFullScreenModeChanged() {
onFullscreenToggle()
}
}
setFullScreen(fullscreen, animated = false, manageKeyboard = true)
(this as MessageComposerView).apply {
setup(isInDarkMode, composerMode)
}
}
},
update = { view ->
// View's been inflated or state read in this block has been updated
// Add logic here if necessary
// As selectedItem is read here, AndroidView will recompose
// whenever the state changes
// Example of Compose -> View communication
val messageComposerView = (view as MessageComposerView)
view.setFullScreen(fullscreen, animated = false, manageKeyboard = false)
messageComposerView.renderComposerMode(composerMode)
messageComposerView.sendButton.isInvisible = !composerCanSendMessage
messageComposerView.setTextIfDifferent(composerText ?: "")
messageComposerView.editText.requestFocus()
val text = composerText.orEmpty()
Row(modifier.padding(
horizontal = 12.dp,
vertical = 8.dp
), verticalAlignment = Alignment.Bottom) {
AttachmentButton(onClick = onAddAttachment, modifier = Modifier.padding(vertical = 6.dp))
Spacer(modifier = Modifier.width(12.dp))
var lineCount by remember { mutableStateOf(0) }
val roundedCorners = remember(lineCount, composerMode) {
if (lineCount > 1 || composerMode is MessageComposerMode.Special) {
RoundedCornerShape(20.dp)
} else {
RoundedCornerShape(28.dp)
}
)
}
val minHeight = 42.dp
Column(
modifier = Modifier
.fillMaxWidth()
.clip(roundedCorners)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, roundedCorners)
) {
if (composerMode is MessageComposerMode.Special) {
ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode)
}
val defaultTypography = ElementTextStyles.Regular.callout.copy(textAlign = TextAlign.Start)
Box {
BasicTextField(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.focusRequester(focusRequester),
value = text,
onValueChange = { onComposerTextChange(it) },
onTextLayout = {
lineCount = it.lineCount
},
textStyle = defaultTypography.copy(color = MaterialTheme.colorScheme.primary),
cursorBrush = SolidColor(LocalColors.current.accentColor),
decorationBox = { innerTextField ->
TextFieldDefaults.DecorationBox(
value = text,
innerTextField = innerTextField,
enabled = true,
singleLine = false,
visualTransformation = VisualTransformation.None,
shape = roundedCorners,
contentPadding = PaddingValues(top = 10.dp, bottom = 10.dp, start = 12.dp, end = 42.dp),
interactionSource = remember { MutableInteractionSource() },
placeholder = {
Text(stringResource(StringR.string.common_message), style = defaultTypography)
},
colors = TextFieldDefaults.colors(
unfocusedTextColor = MaterialTheme.colorScheme.secondary,
focusedTextColor = MaterialTheme.colorScheme.primary,
unfocusedPlaceholderColor = MaterialTheme.colorScheme.secondary,
focusedPlaceholderColor = MaterialTheme.colorScheme.secondary,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
)
)
}
)
SendButton(
text = text,
canSendMessage = composerCanSendMessage,
onSendMessage = onSendMessage,
composerMode = composerMode,
modifier = Modifier.padding(end = 6.dp, bottom = 6.dp)
)
}
}
}
}
@Composable
private fun FakeComposer(
private fun ComposerModeView(
composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
// AndroidView is not Available in this mode, just render a Text
Box(
modifier = modifier
.fillMaxWidth()
.height(80.dp)
) {
Text(
modifier = Modifier
.align(Alignment.Center),
textAlign = TextAlign.Center,
text = "Composer Preview",
fontSize = 20.sp,
color = MaterialTheme.colorScheme.secondary,
)
when (composerMode) {
is MessageComposerMode.Edit -> {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)) {
Icon(
resourceId = VectorIcons.Edit,
contentDescription = stringResource(R.string.editing),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(16.dp),
)
Text(
stringResource(R.string.editing),
style = ElementTextStyles.Regular.caption2,
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(StringR.string.action_close),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = MutableInteractionSource(),
indication = rememberRipple(bounded = false)
),
)
}
}
else -> Unit
}
}
private fun MessageComposerView.setup(isDarkMode: Boolean, composerMode: MessageComposerMode) {
val editTextColor = if (isDarkMode) {
Color.WHITE
} else {
Color.BLACK
@Composable
private fun AttachmentButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier) {
Surface(
Modifier
.size(30.dp)
.clickable(true, onClick = onClick),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {
Image(
modifier = Modifier.size(12.5f.dp),
painter = painterResource(R.drawable.ic_add_attachment),
contentDescription = null,
contentScale = ContentScale.Inside,
colorFilter = ColorFilter.tint(
LocalContentColor.current
)
)
}
}
}
@Composable
private fun BoxScope.SendButton(
text: String,
canSendMessage: Boolean,
onSendMessage: (String) -> Unit,
composerMode: MessageComposerMode,
modifier: Modifier = Modifier,
) {
val interactionSource = MutableInteractionSource()
Box(
modifier = modifier
.clip(CircleShape)
.background(if (canSendMessage) LocalColors.current.accentColor else Color.Transparent)
.size(30.dp)
.align(Alignment.BottomEnd)
.applyIf(composerMode !is MessageComposerMode.Edit, ifTrue = {
padding(start = 1.dp) // Center the arrow in the circle
})
.clickable(
enabled = canSendMessage,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false),
onClick = {
onSendMessage(text)
}),
contentAlignment = Alignment.Center,
) {
val iconId = when (composerMode) {
is MessageComposerMode.Edit -> R.drawable.ic_tick
else -> R.drawable.ic_send
}
val contentDescription = when (composerMode) {
is MessageComposerMode.Edit -> stringResource(StringR.string.action_edit)
else -> stringResource(StringR.string.action_send)
}
Icon(
modifier = Modifier.size(16.dp),
resourceId = iconId,
contentDescription = contentDescription,
tint = if (canSendMessage) Color.White else LocalColors.current.quaternary
)
}
editText.setTextColor(editTextColor)
editText.setHintTextColor(editTextColor)
editText.setHint(R.string.rich_text_editor_composer_placeholder)
emojiButton?.isVisible = true
sendButton.isVisible = true
editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED
renderComposerMode(composerMode)
}
@Preview
@@ -162,15 +297,38 @@ internal fun TextComposerDarkPreview() = ElementPreviewDark { ContentToPreview()
@Composable
private fun ContentToPreview() {
TextComposer(
onSendMessage = {},
fullscreen = false,
onFullscreenToggle = { },
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onCloseSpecialMode = {},
composerCanSendMessage = true,
composerText = "Message",
isInDarkMode = true,
)
Column {
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = false,
composerText = "",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
}
}

View File

@@ -1,55 +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.libraries.textcomposer.tools
import android.text.Spanned
import android.widget.EditText
fun EditText.setTextIfDifferent(newText: CharSequence?): Boolean {
if (!isTextDifferent(newText, text)) {
// Previous text is the same. No op
return false
}
setText(newText)
// Since the text changed we move the cursor to the end of the new text.
// This allows us to fill in text programmatically with a different value,
// but if the user is typing and the view is rebound we won't lose their cursor position.
setSelection(newText?.length ?: 0)
return true
}
private fun isTextDifferent(str1: CharSequence?, str2: CharSequence?): Boolean {
if (str1 === str2) {
return false
}
if (str1 == null || str2 == null) {
return true
}
val length = str1.length
if (length != str2.length) {
return true
}
if (str1 is Spanned) {
return str1 != str2
}
for (i in 0 until length) {
if (str1[i] != str2[i]) {
return true
}
}
return false
}

View File

@@ -1,41 +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.
*/
package io.element.android.libraries.textcomposer.tools
import androidx.transition.Transition
open class SimpleTransitionListener : Transition.TransitionListener {
override fun onTransitionEnd(transition: Transition) {
// No op
}
override fun onTransitionResume(transition: Transition) {
// No op
}
override fun onTransitionPause(transition: Transition) {
// No op
}
override fun onTransitionCancel(transition: Transition) {
// No op
}
override fun onTransitionStart(transition: Transition) {
// No op
}
}

View File

@@ -1,39 +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.libraries.textcomposer.tools
import android.view.ViewGroup
import androidx.transition.ChangeBounds
import androidx.transition.Fade
import androidx.transition.Transition
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
fun ViewGroup.animateLayoutChange(animationDuration: Long, transitionComplete: (() -> Unit)? = null) {
val transition = TransitionSet().apply {
ordering = TransitionSet.ORDERING_SEQUENTIAL
addTransition(ChangeBounds())
addTransition(Fade(Fade.IN))
duration = animationDuration
addListener(object : SimpleTransitionListener() {
override fun onTransitionEnd(transition: Transition) {
transitionComplete?.invoke()
}
})
}
TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition)
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,773Q457.91,773 441.46,756.54Q425,740.09 425,718.31L425,535L241.69,535Q219.91,535 203.46,518.54Q187,502.09 187,480Q187,457.91 203.46,441.46Q219.91,425 241.69,425L425,425L425,241.69Q425,219.91 441.46,203.46Q457.91,187 480,187Q502.09,187 518.54,203.46Q535,219.91 535,241.69L535,425L718.31,425Q740.09,425 756.54,441.46Q773,457.91 773,480Q773,502.09 756.54,518.54Q740.09,535 718.31,535L535,535L535,718.31Q535,740.09 518.54,756.54Q502.09,773 480,773Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M15.404,8.965L1.563,15.882C0.631,16.348 -0.34,15.348 0.116,14.435C0.116,14.435 1.832,10.971 2.303,10.064C2.775,9.156 3.315,8.999 8.331,8.351C8.517,8.327 8.669,8.187 8.669,8C8.669,7.813 8.517,7.673 8.331,7.649C3.315,7.001 2.775,6.844 2.303,5.936C1.832,5.029 0.116,1.565 0.116,1.565C-0.34,0.653 0.631,-0.348 1.563,0.118L15.404,7.036C16.199,7.433 16.199,8.567 15.404,8.965Z"
android:fillColor="#A6ADB7"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="15dp"
android:viewportWidth="16"
android:viewportHeight="15">
<path
android:pathData="M6.518,14.779C6.953,14.779 7.297,14.597 7.535,14.233L15.403,1.968C15.579,1.692 15.65,1.461 15.65,1.234C15.65,0.657 15.245,0.26 14.662,0.26C14.249,0.26 14.009,0.399 13.759,0.792L6.484,12.348L2.736,7.529C2.492,7.205 2.236,7.07 1.874,7.07C1.277,7.07 0.857,7.489 0.857,8.066C0.857,8.315 0.95,8.565 1.158,8.819L5.495,14.245C5.784,14.606 6.096,14.779 6.518,14.779Z"
android:fillColor="#ffffff"/>
</vector>

View File

@@ -98,6 +98,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:mediapickers:impl"))
implementation(project(":libraries:mediaupload:impl"))
implementation(project(":libraries:usersearch:impl"))
implementation(project(":libraries:textcomposer"))
}
fun DependencyHandlerScope.allServicesImpl() {

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