Merge branch 'develop' into feature/fga/media_viewer_actions

This commit is contained in:
ganfra 2023-06-07 16:31:28 +02:00
commit 76e35582da
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() {

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d941ac36664583640991d6a310edf45b4f87d8285647d6aa661970dba3c365b4
size 83119
oid sha256:8296d9d11de5cc99531910ca7252377e084234b09dfb49e1f5b9fa2d1ce602cf
size 83194

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99f4447da79ca0c37c7607fb673b6dfd29cb241ba30420adcc0399db170a5810
size 98150
oid sha256:5c0668727ef819f0f5d2107ce3b6210110879d616c7aeda7d87e54fee1ec6f89
size 99242

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12839404b6e9eaf8983ccf3e5773cd8c80174068e76924ad72b7b6429018b0b6
size 79478
oid sha256:e556931f594409bbf654dd410a25e418935bb8ffd5b7a8eea429a69b0c05424c
size 79592

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4115bdacc08b7c5bdb4c2859d131eaa0e84748d3f42e2de388ffef72131851a2
size 93105
oid sha256:048e556d97ea6301fdb6991776fd4f089e56e227d4babb60c73f350ed97b45a9
size 94026

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d7a3255202968ab6a467b698ca41154b4564882ac3e45578ea36060a8c97583
size 61586
oid sha256:55d18860fbd298703c4f2345fea13bb6edaa065727aed4f043d6ed571a5d091c
size 61509

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0bab25b90e451021883fcd388030ac054729d1ea28a2b4e9b6041f5a103f0a7b
size 99914
oid sha256:68b9ca77722425893f3f28ab5df3770409b5b3a56b64eeae08e7c82d9a1e3e80
size 99857

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a3a36f97c7c22d4113570ce8413de29c1d62e96d1d979851a66f051ca229e08
size 59148
oid sha256:e3b2315704405e618e19f6d3e8d6d19ce0564e621b81a6a78e157ac1b2a0487b
size 59131

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b28cfcb8e238559bd7775507397307fef4e192bb9e1615a0b08f048e73a5e6dd
size 96054
oid sha256:808627e2b8e737b673a03a505e671a96e58ead5b9ccf896ad71e802346dac638
size 96033

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88f8d524d6a22a05b013c8cdc73b909a4ca41938d0fdb41923381c2045e6b37f
size 8990
oid sha256:edfafd24c8085ba7fe218c720b9dc2fead18d729b6ddea3b9fc04b51cb7c0e91
size 9418

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6ff5a856c3af2b85848a80e7d832ebbf737e7d5cf06105d5a01c1e6b3f1eeb7
size 9115
oid sha256:f6b92a6d4f1a2287cefe06c6ba7cb2a2210d61cf4c38747ffd308e4455d78f71
size 9335

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e20ba9c18742b91164f5d0bdbbfe41b3960ab67bf921c2d305cfd72fbe57e52
size 33100
oid sha256:596e4e45b76ab154f3b4011a1ef3fbd27ae801a758949c7a901262c752e7eb6e
size 33461

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3dfa909234ae27da447b12391d53657afd23ef0dd17cff4c8bb55ef1b9811432
size 38347
oid sha256:46cb0ed395274b4201da2bc604695e2cd805ad74cff58cc1ba341d38cb63dd6f
size 38863

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7c1b9dc41e7f4abce90208ce21b2537d8522e1467afdc702735f2d3ab79ed265
size 38320
oid sha256:84aae5ef0c7c9d8308e56b290cdbab809e6e732181fbcbcb22be794d8cb2f34b
size 38780

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9f210f49c4b9a17b01fffe23229afd6dc7909a50ff79732c4d7f169f6c6c558
size 18098
oid sha256:0d59f1042dfdeb97fb2992c21c2c50a6ae1f8535a5b72063c874a91c1ece6076
size 18355

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4782d54f26127fa3e8eb2674486c093036310febc29fc883731eddab158cad40
size 33261
oid sha256:26b409cd4f3c950458e12b1ae3dcd586705edf6f2d79a549aab8b84b59578314
size 33578

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d0741f10b79b3ba739a7d2f160e61df8ea30c7e90d5b03db66cf0fadae39637d
size 31877
oid sha256:c4a7984c30e35b3b1641002b5facfe08dfab34e18c12383f82ae479c5ab57e25
size 32435

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9e818b80358ed35dfe0b1b1d26b8e64a137aa6f2fbce02385cb3cc7900bd852
size 37293
oid sha256:ff299a00cc2b4006fae62e24765a2810fcca23cf56e4b8df86b09e8588a96dc4
size 37631

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18dd6a307f1990b95cddba7e5ae199fc2c9746fdc49231ceb320558707c1ec1c
size 37252
oid sha256:9942eefb85c7525fe17184efa0b5dae53d7b1d76eff618b7ff99e90ae7220893
size 37632

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7c0e24c4fc71151ae8dc04a6213ecc15e0d25dbe6b1684d177086572aeff84c5
size 17289
oid sha256:074e0273e41666a37e1c2d0887956a9949d84364518e89ae31cb2523f1c3b37d
size 17417

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49048bedc855a3460f7a3e7c387481c53e4633ef4110f120c5f8ce649980a583
size 32034
oid sha256:721007e228a1fac6c5e61f6a17ae5d2871fe6515a3f351a4ec91214a5b4e4538
size 32602

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4bddb1e4c74494ed89812a31e35c8d056a3109043fa817c1dbae13d522e56566
size 66641
oid sha256:8340785a1a871d93d9acda9d788d63c91b8270c747bb7b7748b7e532f56ed7e5
size 66547

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4d6e5e784a6ee50cbf767bc0da43b493e910ee2a7ace9890419916d23c1e11a3
size 45461
oid sha256:b3b668c53fbb814eb7c6c3a37f068e9475e0dd286a1099581e17c41b806cfa31
size 48090

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f74cb8516eaf4d8f0e289f685d520bc130df21cbfc4fbc68eed001c4138c83b2
size 45907
oid sha256:43f0d1fbafcde1e07b8ea58a38505916d7aa987aac8d9f3aef323f9001a80344
size 48525

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:360c2c69a3d03538f6f61847757a15e88051b38195cadcd33f9cf2e57638b3f1
size 41761
oid sha256:8662ededaa1c6682d2571e3398d6007dcf92a392efc59985c64bd48d68e2c296
size 43088

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:360c2c69a3d03538f6f61847757a15e88051b38195cadcd33f9cf2e57638b3f1
size 41761
oid sha256:8662ededaa1c6682d2571e3398d6007dcf92a392efc59985c64bd48d68e2c296
size 43088

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75f493a62614704c956b8be9351532bb431cc69e400adc9f1d536579b6dc731c
size 64226
oid sha256:66a2c37dbb7478d45546d8ae404befe0ed0efa7c3ff467566fc4b4b9b76b7628
size 64363

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e96dae6cc767476bd60f23079776600449414a9423976b85c91c348ed065d7ae
size 44877
oid sha256:8219ba85624c3bdc6671b2135aa04d16597fd4087ed38456769c161471984012
size 47443

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fd0bb44da30359d50cf5754768d58d75bf4489c9d6fe9c4d5c0d2f3fd9020541
size 45302
oid sha256:6c0dfdc6684fece8358de955497762f43affd44fc44d35ce8982b2e713b0006f
size 47873

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9654f73bd95d1b0d03dc6ea44d13c4910fb8e64a3c86644a11d5bc8591a4be8a
size 41312
oid sha256:352cce29147cc831da72f7d0a5948d3d0841e05c168f86a09492fa6ab44942e9
size 42405

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9654f73bd95d1b0d03dc6ea44d13c4910fb8e64a3c86644a11d5bc8591a4be8a
size 41312
oid sha256:352cce29147cc831da72f7d0a5948d3d0841e05c168f86a09492fa6ab44942e9
size 42405

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee403297e15204e165ec5dce90e3e93e95e8e1b2e2cbd03489b91c4c9a6de083
size 10360
oid sha256:34d59f799c43ffcaa65b5af54e35a7507376db666093c4d5698d4125bd1e2228
size 10796

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:33237fb8f262176e6f291a8ded90ab56600113c93bed188f6d4d01df950df53e
size 9286
oid sha256:36b3bc2c243a8c09bbdba33f617a6d0fdbb0f5943560e0f23008cc129d1b5682
size 9520

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22afae6cd16ea0134b0966e5fbd7003592b9133e3d57947f062b7c9ebd4694d4
size 33717
oid sha256:14286b8d22070f428630ae1d4244da42f33d4aa4906fbcc09cae4a034ded7707
size 33693

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:68e175fb1c070b17a2f7ef6644837fb37b906b122df737bb9740aa591aaddd34
size 32943
oid sha256:e30d0f0aff589c97e152b48d701a66ff323ab74aa1215af8dc1962bbff1a7204
size 32867

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a48ccf1ff3570915b0673de7c0367d951b795c833d7896645b37000b8dc09c15
size 9649
oid sha256:8c88bfa85e91e6ab247e3feec199c8a29233827dbd2aad28cf9ff00ea9b99117
size 10927

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:945b360a62877d16ec7c25942158ae47e244df85118ca861482ff3bbf0aef55f
size 9268
oid sha256:36adfe10149d33ce817d520d3f542b5441713f7ceddc5864747f624f5e26d432
size 10620

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce6cbcf47c5e76d72fd9bf15e5676510ac29a3087cc862c67ccd4f7de1f24359
size 45546
oid sha256:7fbb93faeae669493b42bc5831b1c5bb67b25ed688d582e01249a9e23e03425a
size 47340

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9af27bab7bbac9914f4e2694f1ea9c200c57edb345bd107cfb5c2edb48cd056e
size 45725
oid sha256:e5e5ac66335825c321666fe9564d1dbbd00ebbfe67f39d89ac133fcbd2b36b70
size 47473

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce6cbcf47c5e76d72fd9bf15e5676510ac29a3087cc862c67ccd4f7de1f24359
size 45546
oid sha256:72bc2d1171ca0d80bfc4522bbbb15c5973dbae362351c344f9b25382da4831d0
size 47701

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99e4c0fe8076952a490ab58807913984a9a6598b589c5e35bc9eececc741bf11
size 44666
oid sha256:603a9bc343feceb5c1f86a36dcfdca7edf71a150237419a16895d12247b032d2
size 46271

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:46660a369ee182afbf49c5d8022fa79c4a358aee7e2aea83bdf08f66a16b5ff1
size 45095
oid sha256:b06f5cdf95f2850a488e71c24c9857eca7b62d147b46b77b3c2ffece75789491
size 46682

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99e4c0fe8076952a490ab58807913984a9a6598b589c5e35bc9eececc741bf11
size 44666
oid sha256:b4a13a0332f1d5ce24824434cb909de2d2bf4af601e6924155d2a550d9e03ec0
size 46634

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18569af3c955f9280122faf1ac653af2f569ee54bfa6d6d74b83adb2813ee04e
size 35446
oid sha256:036d832c19fcba93b8848f9687f4029eba0beeb56893b740062b18c769f419cd
size 35424

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:196cff5b2ee404d5547803039a919f565058a9a0b4c586b05458b63b37b54fd5
size 28153
oid sha256:22eb3320d95bb28b621ff2e7b3421f79b539d84a12591c885f088b3ae404b209
size 28130

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:05fc7b6fa4e71ae48e795422184bcefd809c568f62a0709d08ffca045f078e66
size 55574
oid sha256:55ae80019f919603ec36672b0cc1f0571a9b9135994121fcc2b3420ffc667c40
size 55567

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:119ae9d67a9614e8b461d3a84dac8a59dfa59a72bdee5e178fe317cf2717f5d6
size 34823
oid sha256:b423c2a214c752e980b14181df543f178bf3aa878b0999802cee35d0a79cfb73
size 34801

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:569a281b9ceeadebbf84572aa6f124ccacf8e82d88102b7e7af627350c252afb
size 34453
oid sha256:a02bb25e5802305dcd08aa092f7201fb6e1e59c6e8dc8b6760756b75786de82f
size 34433

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f659dc9210a8165e992efa8b2884535417be7e4b35e83b1fc44b76fab9eeb2a7
size 29956
oid sha256:4ced1625c98be58c1fe1c4f29dc2e4df78abfabda0276274616f09dfefa65f8f
size 30517

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e3c1ac7bb831e3fc9d9ac8500849342095a4a35dff2ee12879051e9bffb73894
size 35541
oid sha256:16db29fe1b26c978613cacadc059bd8d01796785d96444142cd14415154ea1d2
size 35521

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4603362c3c2dfa9e002eb64c14eb0969ff749a4f0315e2b8810a58253ae42d21
size 27992
oid sha256:dac8c13ec5b3086829d50d92efc8cece7e60ffcb475cc01c0a8ccfc3069ae4e7
size 27973

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f199a3afe2771b33953e56af4f30f6ece7bd8ae421efd3b5601f6393c2d07617
size 56336
oid sha256:fbf110969859056024143b3665ddf735e0c772b4706890db194071107910199e
size 56316

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:72847de27dbb5a0126b90c388b14ef443838c5832e8a37f1d29f51b2eb404d97
size 34086
oid sha256:9e45a51563799bd8ca32d56154819cd8df0fa6a466ae48ed809e3e40a2acd3b1
size 34064

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e227432ad7515e2f060ed665861d65f502c354e700fcc428faa6ef20717c56de
size 34100
oid sha256:45c113cdecc92a2210df1bc595159b0a02a115d527bf5f9344b1e177147d6770
size 34076

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7df2eb3bc330ca400199bcf233914b91bd37e83ea6fd6bc9ac2dfe81102b85ed
size 30137
oid sha256:10bcb62743748e2e04ceaf5e82a70ecf42f7361fa88ce0d7d63079ee45ce5209
size 30155

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ce332185060fe0571610e9a060ce6dfafc7619dad384725456a4a755c39a123
size 61131
oid sha256:9e744f45c0a29e3cdc6d0d5417b9702b1a580c557e6d280e046b4a29319bb11f
size 61392

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