Merge branch 'develop' into feature/fga/avoid_deadlocks
This commit is contained in:
commit
da57f42fcc
307 changed files with 2806 additions and 1172 deletions
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
|
|
@ -4,7 +4,7 @@ on:
|
|||
workflow_dispatch:
|
||||
pull_request: { }
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
branches: [ develop ]
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
|
|
@ -34,13 +34,15 @@ jobs:
|
|||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.6.1
|
||||
uses: gradle/gradle-build-action@v2.7.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APK
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
run: ./gradlew assembleDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload debug APKs
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
|
@ -70,8 +72,8 @@ jobs:
|
|||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Compile release sources
|
||||
run: ./gradlew compileReleaseSources $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Compile nightly sources
|
||||
run: ./gradlew compileNightlySources $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew compileNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Compile samples minimal
|
||||
run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES
|
||||
|
|
|
|||
6
.github/workflows/maestro.yml
vendored
6
.github/workflows/maestro.yml
vendored
|
|
@ -38,6 +38,8 @@ jobs:
|
|||
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
- name: Upload debug APKs
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
|
@ -47,7 +49,9 @@ jobs:
|
|||
- uses: mobile-dev-inc/action-maestro-cloud@v1.4.1
|
||||
with:
|
||||
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
|
||||
app-file: app/build/outputs/apk/debug/app-universal-debug.apk
|
||||
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):
|
||||
# app-file should point to an x86 compatible APK file, so upload the x86_64 one (much smaller than the universal APK).
|
||||
app-file: app/build/outputs/apk/debug/app-x86_64-debug.apk
|
||||
env: |
|
||||
USERNAME=maestroelement
|
||||
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
|
||||
|
|
|
|||
11
.github/workflows/nightly.yml
vendored
11
.github/workflows/nightly.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Build and release nightly APK
|
||||
name: Build and release nightly application
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
|
@ -12,7 +12,7 @@ env:
|
|||
|
||||
jobs:
|
||||
nightly:
|
||||
name: Build and publish nightly APK to Firebase
|
||||
name: Build and publish nightly bundle to Firebase
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'vector-im/element-x-android' }}
|
||||
steps:
|
||||
|
|
@ -31,18 +31,21 @@ jobs:
|
|||
sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
|
||||
rm towncrier.toml.bak
|
||||
yes n | towncrier build --version nightly
|
||||
- name: Build and upload Nightly APK
|
||||
- name: Build and upload Nightly application
|
||||
run: |
|
||||
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
|
||||
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
|
||||
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
|
||||
FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
|
||||
- name: Additionally upload Nightly APK to browserstack for testing
|
||||
continue-on-error: true # don't block anything by this upload failing (for now)
|
||||
run: curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/nightly/app-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
|
||||
run: |
|
||||
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/nightly/app-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
|
||||
env:
|
||||
BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
|
||||
BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}
|
||||
|
|
|
|||
2
.github/workflows/nightlyReports.yml
vendored
2
.github/workflows/nightlyReports.yml
vendored
|
|
@ -62,7 +62,7 @@ jobs:
|
|||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.6.1
|
||||
uses: gradle/gradle-build-action@v2.7.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Dependency analysis
|
||||
|
|
|
|||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.6.1
|
||||
uses: gradle/gradle-build-action@v2.7.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run code quality check suite
|
||||
|
|
|
|||
2
.github/workflows/recordScreenshots.yml
vendored
2
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
java-version: '17'
|
||||
# Add gradle cache, this should speed up the process
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.6.1
|
||||
uses: gradle/gradle-build-action@v2.7.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Record screenshots
|
||||
|
|
|
|||
40
.github/workflows/release.yml
vendored
Normal file
40
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: Create release App Bundle
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create App Bundle
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/head/main' && format('build-release-main-{0}', github.sha) }}
|
||||
cancel-in-progress: true
|
||||
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.7.0
|
||||
- name: Create app bundle
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
run: ./gradlew bundleRelease $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload bundle as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: elementx-app-bundle-unsigned
|
||||
path: |
|
||||
app/build/outputs/bundle/release/app-release.aab
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.6.1
|
||||
uses: gradle/gradle-build-action@v2.7.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -38,6 +38,7 @@ captures/
|
|||
# IntelliJ
|
||||
*.iml
|
||||
.idea/.name
|
||||
.idea/androidTestResultsUserPreferences.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/compiler.xml
|
||||
.idea/deploymentTargetDropDown.xml
|
||||
|
|
|
|||
1
.idea/dictionaries/shared.xml
generated
1
.idea/dictionaries/shared.xml
generated
|
|
@ -8,6 +8,7 @@
|
|||
<w>measurables</w>
|
||||
<w>onboarding</w>
|
||||
<w>placeables</w>
|
||||
<w>posthog</w>
|
||||
<w>showkase</w>
|
||||
<w>snackbar</w>
|
||||
<w>swipeable</w>
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ android {
|
|||
// "App Distribution found more than 1 output file for this variant.
|
||||
// Please contact firebase-support@google.com for help using APK splits with App Distribution."
|
||||
artifactPath = "$rootDir/app/build/outputs/apk/nightly/app-universal-nightly.apk"
|
||||
// artifactType = "AAB"
|
||||
// artifactPath = "$rootDir/app/build/outputs/bundle/nightly/app-nightly.aab"
|
||||
// This file will be generated by the GitHub action
|
||||
releaseNotesFile = "CHANGES_NIGHTLY.md"
|
||||
groups = "external-testers"
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ import io.element.android.features.login.api.oidc.OidcAction
|
|||
import io.element.android.features.login.api.oidc.OidcIntentResolver
|
||||
import io.element.android.libraries.deeplink.DeeplinkData
|
||||
import io.element.android.libraries.deeplink.DeeplinkParser
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import android.os.Parcelable
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
|
|
@ -161,13 +162,16 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
// Rely on the View Lifecycle instead of the Node Lifecycle,
|
||||
// Rely on the View Lifecycle in addition to the Node Lifecycle,
|
||||
// because this node enters 'onDestroy' before his children, so it can leads to
|
||||
// using the room in a child node where it's already closed.
|
||||
DisposableEffect(Unit) {
|
||||
inputs.room.open()
|
||||
inputs.room.subscribeToSync()
|
||||
onDispose {
|
||||
inputs.room.close()
|
||||
inputs.room.unsubscribeFromSync()
|
||||
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
|
||||
inputs.room.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
Children(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.appnav
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -38,7 +38,7 @@ class RootPresenterTest {
|
|||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
@ -54,7 +54,7 @@ class RootPresenterTest {
|
|||
showError("Bad news", "Something bad happened")
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.appnav.loggedin
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -34,7 +34,7 @@ class LoggedInPresenterTest {
|
|||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -90,6 +90,14 @@ allprojects {
|
|||
apply {
|
||||
plugin("org.owasp.dependencycheck")
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||
// Warnings are potential errors, so stop ignoring them
|
||||
// This is disabled by default, but the CI will enforce this.
|
||||
// You can override by passing `-PallWarningsAsErrors=true` in the command line
|
||||
// Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file
|
||||
kotlinOptions.allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true"
|
||||
}
|
||||
}
|
||||
|
||||
// To run a sonar analysis:
|
||||
|
|
|
|||
17
docs/maps.md
17
docs/maps.md
|
|
@ -27,16 +27,21 @@ Place your API key in `local.properties` with the key
|
|||
services.maptiler.apikey=abCd3fGhijK1mN0pQr5t
|
||||
```
|
||||
|
||||
Optionally you can also place your custom MapTyler style ids for light and dark maps
|
||||
in the `local.properties` with the keys `services.maptiler.lightMapId` and
|
||||
`services.maptiler.darkMapId`. If you don't specify these, the default MapTiler "basic-v2"
|
||||
styles will be used.
|
||||
|
||||
## Making releasable builds with MapTiler
|
||||
|
||||
To insert the MapTiler API key when building an APK, set the
|
||||
`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build
|
||||
environment.
|
||||
environment.
|
||||
If you've added custom styles also set the `ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID`
|
||||
and `ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID` environment variables accordingly.
|
||||
|
||||
## Using other map sources or MapTiler styles
|
||||
|
||||
If you wish to use an alternative map provider, or custom MapTiler styles,
|
||||
you can customise the functions in
|
||||
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`.
|
||||
We've kept this file small and self contained to minimise the chances of merge
|
||||
collisions in forks.
|
||||
If you wish to use an alternative map provider, you can provide your own implementations of
|
||||
`TileServerStyleUriBuilder` and `StaticMapUrlBuilder` in
|
||||
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/`.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,4 @@ dependencies {
|
|||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.features.analytics.test)
|
||||
testImplementation(projects.features.analytics.impl)
|
||||
|
||||
androidTestImplementation(libs.test.junitext)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.analytics.impl
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -35,7 +35,7 @@ class AnalyticsOptInPresenterTest {
|
|||
aBuildMeta(),
|
||||
analyticsService
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -53,7 +53,7 @@ class AnalyticsOptInPresenterTest {
|
|||
aBuildMeta(),
|
||||
analyticsService
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.analytics.impl.preferences
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -33,7 +33,7 @@ class AnalyticsPreferencesPresenterTest {
|
|||
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
|
||||
aBuildMeta()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
@ -48,7 +48,7 @@ class AnalyticsPreferencesPresenterTest {
|
|||
FakeAnalyticsService(isEnabled = false, didAskUserConsent = false),
|
||||
aBuildMeta()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -62,7 +62,7 @@ class AnalyticsPreferencesPresenterTest {
|
|||
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
|
||||
aBuildMeta()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class FakeAnalyticsService(
|
|||
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
|
||||
val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
|
||||
|
||||
override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList()
|
||||
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
|
||||
|
||||
override fun getUserConsent(): Flow<Boolean> = isEnabledFlow
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,5 @@ dependencies {
|
|||
testImplementation(projects.libraries.mediaupload.test)
|
||||
testImplementation(projects.libraries.usersearch.test)
|
||||
|
||||
androidTestImplementation(libs.test.junitext)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem
|
||||
import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.createroom.impl.addpeople
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -43,7 +43,7 @@ class AddPeoplePresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// TODO This doesn't actually test anything...
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
package io.element.android.features.createroom.impl.configureroom
|
||||
|
||||
import android.net.Uri
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -93,7 +93,7 @@ class ConfigureRoomPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -108,7 +108,7 @@ class ConfigureRoomPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - create room button is enabled only if the required fields are completed`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -133,7 +133,7 @@ class ConfigureRoomPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - state is updated when fields are changed`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -203,7 +203,7 @@ class ConfigureRoomPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - trigger create room action`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -221,7 +221,7 @@ class ConfigureRoomPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - record analytics when creating room`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -240,7 +240,7 @@ class ConfigureRoomPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - trigger create room with upload error and retry`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
@ -265,7 +265,7 @@ class ConfigureRoomPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - trigger retry and cancel actions`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -27,8 +27,6 @@ import io.element.android.features.createroom.impl.userlist.FakeUserListPresente
|
|||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
|
@ -68,7 +66,7 @@ class CreateRoomRootPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -82,7 +80,7 @@ class CreateRoomRootPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - trigger create DM action`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -102,7 +100,7 @@ class CreateRoomRootPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - creating a DM records analytics event`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -123,7 +121,7 @@ class CreateRoomRootPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - trigger retrieve DM action`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -142,7 +140,7 @@ class CreateRoomRootPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - trigger retry create DM action`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.createroom.impl.userlist
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -41,7 +41,7 @@ class DefaultUserListPresenterTests {
|
|||
userRepository,
|
||||
UserListDataStore(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
@ -62,7 +62,7 @@ class DefaultUserListPresenterTests {
|
|||
userRepository,
|
||||
UserListDataStore(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
@ -83,7 +83,7 @@ class DefaultUserListPresenterTests {
|
|||
userRepository,
|
||||
UserListDataStore(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
@ -119,7 +119,7 @@ class DefaultUserListPresenterTests {
|
|||
userRepository,
|
||||
UserListDataStore(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
@ -158,7 +158,7 @@ class DefaultUserListPresenterTests {
|
|||
userRepository,
|
||||
UserListDataStore(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
@ -183,7 +183,7 @@ class DefaultUserListPresenterTests {
|
|||
userRepository,
|
||||
UserListDataStore(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_welcome_bullet_1">"Anrufe, Standortfreigabe, Suche und mehr werden später in diesem Jahr hinzugefügt."</string>
|
||||
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
|
||||
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."</string>
|
||||
<string name="screen_welcome_button">"Los geht\'s!"</string>
|
||||
<string name="screen_welcome_subtitle">"Folgendes musst du wissen:"</string>
|
||||
<string name="screen_welcome_title">"Willkommen bei %1$s!"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_welcome_bullet_2">"L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string>
|
||||
<string name="screen_welcome_bullet_3">"Nous serions ravis d’avoir votre avis, n’hésitez pas à nous le partager via la page des paramètres."</string>
|
||||
<string name="screen_welcome_button">"C’est parti !"</string>
|
||||
<string name="screen_welcome_subtitle">"Voici ce qu’il faut savoir :"</string>
|
||||
<string name="screen_welcome_title">"Bienvenue sur %1$s !"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_welcome_bullet_1">"Hovory, zdieľanie polohy, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string>
|
||||
<string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string>
|
||||
<string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string>
|
||||
<string name="screen_welcome_button">"Poďme na to!"</string>
|
||||
<string name="screen_welcome_subtitle">"Tu je to, čo potrebujete vedieť:"</string>
|
||||
<string name="screen_welcome_title">"Vitajte v %1$s!"</string>
|
||||
</resources>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string>
|
||||
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
|
||||
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms won’t be available in this update."</string>
|
||||
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
||||
<string name="screen_welcome_button">"Let\'s go!"</string>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import io.element.android.services.analytics.api.AnalyticsService
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.invitelist.impl
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
|
|
@ -55,7 +55,7 @@ class InviteListPresenterTests {
|
|||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -76,7 +76,7 @@ class InviteListPresenterTests {
|
|||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val withInviteState = awaitItem()
|
||||
|
|
@ -102,7 +102,7 @@ class InviteListPresenterTests {
|
|||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val withInviteState = awaitItem()
|
||||
|
|
@ -131,7 +131,7 @@ class InviteListPresenterTests {
|
|||
FakeAnalyticsService(),
|
||||
FakeNotificationDrawerManager()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val originalState = awaitItem()
|
||||
|
|
@ -152,7 +152,7 @@ class InviteListPresenterTests {
|
|||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val originalState = awaitItem()
|
||||
|
|
@ -173,7 +173,7 @@ class InviteListPresenterTests {
|
|||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val originalState = awaitItem()
|
||||
|
|
@ -199,7 +199,7 @@ class InviteListPresenterTests {
|
|||
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val originalState = awaitItem()
|
||||
|
|
@ -227,7 +227,7 @@ class InviteListPresenterTests {
|
|||
room.givenLeaveRoomError(ex)
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val originalState = awaitItem()
|
||||
|
|
@ -257,7 +257,7 @@ class InviteListPresenterTests {
|
|||
room.givenLeaveRoomError(ex)
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val originalState = awaitItem()
|
||||
|
|
@ -288,7 +288,7 @@ class InviteListPresenterTests {
|
|||
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val originalState = awaitItem()
|
||||
|
|
@ -313,7 +313,7 @@ class InviteListPresenterTests {
|
|||
room.givenJoinRoomResult(Result.failure(ex))
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val originalState = awaitItem()
|
||||
|
|
@ -335,7 +335,7 @@ class InviteListPresenterTests {
|
|||
room.givenJoinRoomResult(Result.failure(ex))
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val originalState = awaitItem()
|
||||
|
|
@ -362,7 +362,7 @@ class InviteListPresenterTests {
|
|||
FakeAnalyticsService(),
|
||||
FakeNotificationDrawerManager()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem()
|
||||
|
|
@ -400,7 +400,7 @@ class InviteListPresenterTests {
|
|||
FakeAnalyticsService(),
|
||||
FakeNotificationDrawerManager()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.leaveroom.impl
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -40,7 +40,7 @@ class LeaveRoomPresenterImplTest {
|
|||
@Test
|
||||
fun `present - initial state hides all dialogs`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -60,7 +60,7 @@ class LeaveRoomPresenterImplTest {
|
|||
)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -80,7 +80,7 @@ class LeaveRoomPresenterImplTest {
|
|||
)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -100,7 +100,7 @@ class LeaveRoomPresenterImplTest {
|
|||
)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -122,7 +122,7 @@ class LeaveRoomPresenterImplTest {
|
|||
},
|
||||
roomMembershipObserver = roomMembershipObserver
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -145,7 +145,7 @@ class LeaveRoomPresenterImplTest {
|
|||
)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -167,7 +167,7 @@ class LeaveRoomPresenterImplTest {
|
|||
)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -191,7 +191,7 @@ class LeaveRoomPresenterImplTest {
|
|||
)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -22,12 +22,12 @@ plugins {
|
|||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
fun readLocalProperty(name: String) = Properties().apply {
|
||||
fun readLocalProperty(name: String): String? = Properties().apply {
|
||||
try {
|
||||
load(rootProject.file("local.properties").reader())
|
||||
} catch (ignored: java.io.IOException) {
|
||||
}
|
||||
}[name]
|
||||
}.getProperty(name)
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.location.api"
|
||||
|
|
@ -37,9 +37,23 @@ android {
|
|||
type = "string",
|
||||
name = "maptiler_api_key",
|
||||
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
|
||||
?: readLocalProperty("services.maptiler.apikey") as? String
|
||||
?: readLocalProperty("services.maptiler.apikey")
|
||||
?: ""
|
||||
)
|
||||
resValue(
|
||||
type = "string",
|
||||
name = "maptiler_light_map_id",
|
||||
value = System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
|
||||
?: readLocalProperty("services.maptiler.lightMapId")
|
||||
?: "basic-v2" // fall back to maptiler's default light map.
|
||||
)
|
||||
resValue(
|
||||
type = "string",
|
||||
name = "maptiler_dark_map_id",
|
||||
value = System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
|
||||
?: readLocalProperty("services.maptiler.darkMapId")
|
||||
?: "basic-v2-dark" // fall back to maptiler's default dark map.
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,16 +29,16 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import io.element.android.features.location.api.internal.StaticMapPlaceholder
|
||||
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
|
||||
import io.element.android.features.location.api.internal.centerBottomEdge
|
||||
import io.element.android.features.location.api.internal.staticMapUrl
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import timber.log.Timber
|
||||
|
|
@ -65,23 +65,22 @@ fun StaticMapView(
|
|||
) {
|
||||
val context = LocalContext.current
|
||||
var retryHash by remember { mutableStateOf(0) }
|
||||
val builder = remember { StaticMapUrlBuilder(context) }
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = if (constraints.isZero) {
|
||||
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
|
||||
null
|
||||
} else {
|
||||
ImageRequest.Builder(LocalContext.current)
|
||||
ImageRequest.Builder(context)
|
||||
.data(
|
||||
staticMapUrl(
|
||||
context = context,
|
||||
builder.build(
|
||||
lat = lat,
|
||||
lon = lon,
|
||||
zoom = zoom,
|
||||
darkMode = darkMode,
|
||||
// Size the map based on DP rather than pixels, as otherwise the features and attribution
|
||||
// end up being illegibly tiny on high density displays.
|
||||
width = constraints.maxWidth.toDp().value.toInt(),
|
||||
height = constraints.maxHeight.toDp().value.toInt(),
|
||||
width = constraints.maxWidth,
|
||||
height = constraints.maxHeight,
|
||||
density = LocalDensity.current.density,
|
||||
)
|
||||
)
|
||||
.size(width = constraints.maxWidth, height = constraints.maxHeight)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import android.content.Context
|
||||
import io.element.android.features.location.api.R
|
||||
|
||||
internal const val MAPTILER_BASE_URL = "https://api.maptiler.com/maps"
|
||||
|
||||
internal fun Context.mapId(darkMode: Boolean) = when (darkMode) {
|
||||
true -> getString(R.string.maptiler_dark_map_id)
|
||||
false -> getString(R.string.maptiler_light_map_id)
|
||||
}
|
||||
|
||||
internal val Context.apiKey: String
|
||||
get() = getString(R.string.maptiler_api_key)
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import android.content.Context
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Builds an URL for MapTiler's Static Maps API.
|
||||
*
|
||||
* https://docs.maptiler.com/cloud/api/static-maps/
|
||||
*/
|
||||
internal class MapTilerStaticMapUrlBuilder(
|
||||
private val apiKey: String,
|
||||
private val lightMapId: String,
|
||||
private val darkMapId: String,
|
||||
) : StaticMapUrlBuilder {
|
||||
|
||||
constructor(context: Context) : this(
|
||||
apiKey = context.apiKey,
|
||||
lightMapId = context.mapId(darkMode = false),
|
||||
darkMapId = context.mapId(darkMode = true),
|
||||
)
|
||||
|
||||
override fun build(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
zoom: Double,
|
||||
darkMode: Boolean,
|
||||
width: Int,
|
||||
height: Int,
|
||||
density: Float
|
||||
): String {
|
||||
val mapId = if (darkMode) darkMapId else lightMapId
|
||||
val finalZoom = zoom.coerceIn(zoomRange)
|
||||
|
||||
// Request @2x density for xhdpi and above (xhdpi == 320dpi == 2x density).
|
||||
val is2x = density >= 2
|
||||
|
||||
// Scale requested width/height according to the reported display density.
|
||||
val (finalWidth, finalHeight) = coerceWidthAndHeight(
|
||||
width = (width / density).roundToInt(),
|
||||
height = (height / density).roundToInt(),
|
||||
is2x = is2x,
|
||||
)
|
||||
|
||||
val scale = if (is2x) "@2x" else ""
|
||||
|
||||
// Since Maptiler doesn't support arbitrary dpi scaling, we stick to 2x sized
|
||||
// images even on displays with density higher than 2x, thereby yielding an
|
||||
// image smaller than the available space in pixels.
|
||||
// The resulting image will have to be scaled to fit the available space in order
|
||||
// to keep the perceived content size constant at the expense of sharpness.
|
||||
return "$MAPTILER_BASE_URL/${mapId}/static/${lon},${lat},${finalZoom}/${finalWidth}x${finalHeight}${scale}.webp?key=${apiKey}&attribution=bottomleft"
|
||||
}
|
||||
}
|
||||
|
||||
private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair<Int, Int> {
|
||||
if (width <= 0 || height <= 0) {
|
||||
// This effectively yields an URL which asks for a 0x0 image which will result in an HTTP error,
|
||||
// but it's better than e.g. asking for a 1x1 image which would be unreadable and increase usage costs.
|
||||
return 0 to 0
|
||||
}
|
||||
val aspectRatio = width.toDouble() / height.toDouble()
|
||||
val range = if (is2x) widthHeightRange2x else widthHeightRange
|
||||
return if (width >= height) {
|
||||
width.coerceIn(range).let { coercedWidth ->
|
||||
coercedWidth to (coercedWidth / aspectRatio).roundToInt()
|
||||
}
|
||||
} else {
|
||||
height.coerceIn(range).let { coercedHeight ->
|
||||
(coercedHeight * aspectRatio).roundToInt() to coercedHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val widthHeightRange = 1..2048 // API will error if outside 1-2048 range @1x.
|
||||
private val widthHeightRange2x = 1..1024 // API will error if outside 1-1024 range @2x.
|
||||
private val zoomRange = 0.0..22.0 // API will error if outside 0-22 range.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:JvmName("TileServerStyleUriBuilderKt")
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import android.content.Context
|
||||
|
||||
internal class MapTilerTileServerStyleUriBuilder(
|
||||
private val apiKey: String,
|
||||
private val lightMapId: String,
|
||||
private val darkMapId: String,
|
||||
) : TileServerStyleUriBuilder {
|
||||
|
||||
constructor(context: Context) : this(
|
||||
apiKey = context.apiKey,
|
||||
lightMapId = context.mapId(darkMode = false),
|
||||
darkMapId = context.mapId(darkMode = true),
|
||||
)
|
||||
|
||||
override fun build(darkMode: Boolean): String {
|
||||
val mapId = if (darkMode) darkMapId else lightMapId
|
||||
return "${MAPTILER_BASE_URL}/${mapId}/style.json?key=${apiKey}"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.element.android.features.location.api.R
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
/**
|
||||
* Provides the URL to an image that contains a statically-generated map of the given location.
|
||||
*/
|
||||
fun staticMapUrl(
|
||||
context: Context,
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
zoom: Double,
|
||||
width: Int,
|
||||
height: Int,
|
||||
darkMode: Boolean,
|
||||
): String {
|
||||
return "${baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft"
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to remember the tile server URL based on the current theme.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTileStyleUrl(): String {
|
||||
val context = LocalContext.current
|
||||
val darkMode = !ElementTheme.isLightTheme
|
||||
return remember(darkMode) {
|
||||
tileStyleUrl(
|
||||
context = context,
|
||||
darkMode = darkMode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the URL to a MapLibre style document, used for rendering dynamic maps.
|
||||
*/
|
||||
private fun tileStyleUrl(
|
||||
context: Context,
|
||||
darkMode: Boolean,
|
||||
): String {
|
||||
return "${baseUrl(darkMode)}/style.json?key=${context.apiKey}"
|
||||
}
|
||||
|
||||
private fun baseUrl(darkMode: Boolean) =
|
||||
"https://api.maptiler.com/maps/" +
|
||||
if (darkMode)
|
||||
"dea61faf-292b-4774-9660-58fcef89a7f3"
|
||||
else
|
||||
"9bc819c8-e627-474a-a348-ec144fe3d810"
|
||||
|
||||
private val Context.apiKey: String
|
||||
get() = getString(R.string.maptiler_api_key)
|
||||
|
|
@ -29,7 +29,7 @@ fun Modifier.centerBottomEdge(scope: BoxScope): Modifier = with(scope) {
|
|||
Modifier.align { size, space, _ ->
|
||||
IntOffset(
|
||||
x = (space.width - size.width) / 2,
|
||||
y = (space.height / 2) - size.height,
|
||||
y = space.height / 2 - size.height,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Builds an URL for a 3rd party service provider static maps API.
|
||||
*/
|
||||
interface StaticMapUrlBuilder {
|
||||
fun build(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
zoom: Double,
|
||||
darkMode: Boolean,
|
||||
width: Int,
|
||||
height: Int,
|
||||
density: Float,
|
||||
): String
|
||||
}
|
||||
|
||||
fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
/**
|
||||
* Builds a style URI for a MapLibre compatible tile server.
|
||||
*
|
||||
* Used for rendering dynamic maps.
|
||||
*/
|
||||
interface TileServerStyleUriBuilder {
|
||||
fun build(
|
||||
darkMode: Boolean,
|
||||
): String
|
||||
}
|
||||
|
||||
fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder(context = context)
|
||||
|
||||
/**
|
||||
* Provides and remembers a style URI for a MapLibre compatible tile server.
|
||||
*
|
||||
* Used for rendering dynamic maps.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTileStyleUrl(): String {
|
||||
val context = LocalContext.current
|
||||
val darkMode = !ElementTheme.isLightTheme
|
||||
return remember(darkMode) {
|
||||
TileServerStyleUriBuilder(context).build(darkMode)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class MapTilerStaticMapUrlBuilderTest {
|
||||
|
||||
private val builder = MapTilerStaticMapUrlBuilder(
|
||||
apiKey = "anApiKey",
|
||||
lightMapId = "aLightMapId",
|
||||
darkMapId = "aDarkMapId",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `static map 1x density`() {
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 800,
|
||||
height = 600,
|
||||
density = 1f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `static map 1,5x density`() {
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 1200,
|
||||
height = 900,
|
||||
density = 1.5f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `static map 2x density`() {
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 1600,
|
||||
height = 1200,
|
||||
density = 2f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `static map 3x density`() {
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 2400,
|
||||
height = 1800,
|
||||
density = 3f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too big image is coerced keeping aspect ratio`() {
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 4096,
|
||||
height = 2048,
|
||||
density = 1f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
|
||||
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 2048,
|
||||
height = 4096,
|
||||
density = 1f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
|
||||
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 4096,
|
||||
height = 2048,
|
||||
density = 2f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 2048,
|
||||
height = 4096,
|
||||
density = 2f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = Int.MAX_VALUE,
|
||||
height = Int.MAX_VALUE,
|
||||
density = 2f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too small image is coerced to 0x0`() {
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 0,
|
||||
height = 0,
|
||||
density = 1f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
|
||||
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = 0,
|
||||
height = 0,
|
||||
density = 2f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||
|
||||
assertThat(
|
||||
builder.build(
|
||||
lat = 1.23,
|
||||
lon = -4.56,
|
||||
zoom = 7.8,
|
||||
darkMode = false,
|
||||
width = Int.MIN_VALUE,
|
||||
height = Int.MIN_VALUE,
|
||||
density = 1f,
|
||||
)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class MapTilerTileServerStyleUriBuilderTest {
|
||||
|
||||
private val builder = MapTilerTileServerStyleUriBuilder(
|
||||
apiKey = "anApiKey",
|
||||
lightMapId = "aLightMapId",
|
||||
darkMapId = "aDarkMapId",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `light map uri`() {
|
||||
assertThat(
|
||||
builder.build(darkMode = false)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/style.json?key=anApiKey")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dark map uri`() {
|
||||
assertThat(
|
||||
builder.build(darkMode = true)
|
||||
).isEqualTo("https://api.maptiler.com/maps/aDarkMapId/style.json?key=anApiKey")
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,6 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
implementation(libs.dagger)
|
||||
implementation(projects.anvilannotations)
|
||||
implementation(projects.services.toolbox.api)
|
||||
anvil(projects.anvilcodegen)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import android.Manifest
|
|||
import android.view.Gravity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.mapbox.mapboxsdk.camera.CameraPosition
|
||||
import com.mapbox.mapboxsdk.geometry.LatLng
|
||||
import io.element.android.libraries.maplibre.compose.MapLocationSettings
|
||||
|
|
@ -53,6 +54,13 @@ object MapDefaults {
|
|||
val locationSettings: MapLocationSettings
|
||||
get() = MapLocationSettings(
|
||||
locationEnabled = false,
|
||||
backgroundTintColor = Color.White,
|
||||
foregroundTintColor = Color.Black,
|
||||
backgroundStaleTintColor = Color.White,
|
||||
foregroundStaleTintColor = Color.Black,
|
||||
accuracyColor = Color.Black,
|
||||
pulseEnabled = true,
|
||||
pulseColor = Color.Black,
|
||||
)
|
||||
|
||||
val centerCameraPosition = CameraPosition.Builder()
|
||||
|
|
|
|||
|
|
@ -36,12 +36,7 @@ import io.element.android.libraries.core.meta.BuildMeta
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
class SendLocationPresenter @Inject constructor(
|
||||
|
|
@ -50,7 +45,6 @@ class SendLocationPresenter @Inject constructor(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val messageComposerContext: MessageComposerContext,
|
||||
private val locationActions: LocationActions,
|
||||
private val systemClock: SystemClock,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<SendLocationState> {
|
||||
|
||||
|
|
@ -115,7 +109,7 @@ class SendLocationPresenter @Inject constructor(
|
|||
SendLocationState.Mode.PinLocation -> {
|
||||
val geoUri = event.cameraPosition.toGeoUri()
|
||||
room.sendLocation(
|
||||
body = generateBody(geoUri, systemClock.epochMillis()),
|
||||
body = generateBody(geoUri),
|
||||
geoUri = geoUri,
|
||||
description = null,
|
||||
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
|
||||
|
|
@ -134,7 +128,7 @@ class SendLocationPresenter @Inject constructor(
|
|||
SendLocationState.Mode.SenderLocation -> {
|
||||
val geoUri = event.toGeoUri()
|
||||
room.sendLocation(
|
||||
body = generateBody(geoUri, systemClock.epochMillis()),
|
||||
body = generateBody(geoUri),
|
||||
geoUri = geoUri,
|
||||
description = null,
|
||||
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
|
||||
|
|
@ -158,7 +152,4 @@ private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeo
|
|||
|
||||
private fun SendLocationEvents.SendLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon"
|
||||
|
||||
private fun generateBody(uri: String, epochMillis: Long): String {
|
||||
val timestamp = ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
|
||||
return "Location was shared at $uri as of $timestamp"
|
||||
}
|
||||
private fun generateBody(uri: String): String = "Location was shared at $uri"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.LocationSearching
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
|
@ -49,6 +48,7 @@ import io.element.android.features.location.api.Location
|
|||
import io.element.android.features.location.api.internal.centerBottomEdge
|
||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||
import io.element.android.features.location.impl.MapDefaults
|
||||
import io.element.android.features.location.impl.R
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
|
|
@ -156,7 +156,11 @@ fun SendLocationView(
|
|||
navigateUp()
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.LocationOn, null)
|
||||
Icon(
|
||||
resourceId = R.drawable.pin_small,
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp + navBarPadding))
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
|||
isTrackMyLocation = false,
|
||||
eventSink = {},
|
||||
),
|
||||
ShowLocationState(
|
||||
Location(1.23, 2.34, 4f),
|
||||
description = "For some reason I decided to to write a small essay that wraps at just two lines!",
|
||||
hasLocationPermission = false,
|
||||
isTrackMyLocation = false,
|
||||
eventSink = {},
|
||||
),
|
||||
ShowLocationState(
|
||||
Location(1.23, 2.34, 4f),
|
||||
description = "For some reason I decided to write a small essay in the location description. " +
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import com.mapbox.mapboxsdk.camera.CameraPosition
|
|||
import com.mapbox.mapboxsdk.geometry.LatLng
|
||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||
import io.element.android.features.location.impl.MapDefaults
|
||||
import io.element.android.features.location.impl.send.SendLocationState
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="26dp"
|
||||
android:height="28dp"
|
||||
android:viewportWidth="26"
|
||||
android:viewportHeight="28">
|
||||
<path
|
||||
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
|
||||
android:fillColor="#EBEEF2"/>
|
||||
<path
|
||||
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
|
||||
android:fillColor="#EBEEF2"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M6.74,6.74h12.444v12.444h-12.444z"/>
|
||||
<path
|
||||
android:pathData="M12.962,6.74C10.554,6.74 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.74 12.962,6.74ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
|
||||
android:fillColor="#101317"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
features/location/impl/src/main/res/drawable/pin_small.xml
Normal file
19
features/location/impl/src/main/res/drawable/pin_small.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="26dp"
|
||||
android:height="28dp"
|
||||
android:viewportWidth="26"
|
||||
android:viewportHeight="28">
|
||||
<path
|
||||
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<path
|
||||
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M6.74,6.741h12.444v12.444h-12.444z"/>
|
||||
<path
|
||||
android:pathData="M12.962,6.741C10.554,6.741 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.741 12.962,6.741ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.location.impl.send
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
|
|
@ -34,7 +34,6 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
|
|||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -46,7 +45,6 @@ class SendLocationPresenterTest {
|
|||
private val fakeAnalyticsService = FakeAnalyticsService()
|
||||
private val messageComposerContextFake = MessageComposerContextFake()
|
||||
private val fakeLocationActions = FakeLocationActions()
|
||||
private val fakeSystemClock = SystemClock { 0L }
|
||||
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
|
||||
private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
|
||||
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
|
||||
|
|
@ -56,7 +54,6 @@ class SendLocationPresenterTest {
|
|||
analyticsService = fakeAnalyticsService,
|
||||
messageComposerContext = messageComposerContextFake,
|
||||
locationActions = fakeLocationActions,
|
||||
systemClock = fakeSystemClock,
|
||||
buildMeta = fakeBuildMeta,
|
||||
)
|
||||
|
||||
|
|
@ -69,7 +66,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
|
||||
|
|
@ -96,7 +93,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
|
||||
|
|
@ -123,7 +120,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -149,7 +146,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -175,7 +172,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
// Skip initial state
|
||||
|
|
@ -206,7 +203,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
// Skip initial state
|
||||
|
|
@ -234,7 +231,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
// Skip initial state
|
||||
|
|
@ -265,7 +262,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
// Skip initial state
|
||||
|
|
@ -292,7 +289,7 @@ class SendLocationPresenterTest {
|
|||
Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
|
||||
Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
|
||||
SendLocationInvocation(
|
||||
body = "Location was shared at geo:3.0,4.0;u=5.0 as of 1970-01-01T00:00:00Z",
|
||||
body = "Location was shared at geo:3.0,4.0;u=5.0",
|
||||
geoUri = "geo:3.0,4.0;u=5.0",
|
||||
description = null,
|
||||
zoomLevel = 15,
|
||||
|
|
@ -322,7 +319,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
// Skip initial state
|
||||
|
|
@ -349,7 +346,7 @@ class SendLocationPresenterTest {
|
|||
Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
|
||||
Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
|
||||
SendLocationInvocation(
|
||||
body = "Location was shared at geo:0.0,1.0 as of 1970-01-01T00:00:00Z",
|
||||
body = "Location was shared at geo:0.0,1.0",
|
||||
geoUri = "geo:0.0,1.0",
|
||||
description = null,
|
||||
zoomLevel = 15,
|
||||
|
|
@ -384,7 +381,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
}
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
// Skip initial state
|
||||
|
|
@ -431,7 +428,7 @@ class SendLocationPresenterTest {
|
|||
)
|
||||
}
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
// Skip initial state
|
||||
|
|
@ -451,7 +448,7 @@ class SendLocationPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `application name is in state`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
sendLocationPresenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.location.impl.show
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
|
|
@ -44,7 +44,7 @@ class ShowLocationPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `emits initial state with no location permission`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -59,7 +59,7 @@ class ShowLocationPresenterTest {
|
|||
fun `emits initial state with location permission`() = runTest {
|
||||
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -74,7 +74,7 @@ class ShowLocationPresenterTest {
|
|||
fun `emits initial state with partial location permission`() = runTest {
|
||||
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -87,7 +87,7 @@ class ShowLocationPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `uses action to share location`() = runTest {
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -102,7 +102,7 @@ class ShowLocationPresenterTest {
|
|||
fun `centers on user location`() = runTest {
|
||||
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -61,6 +61,4 @@ dependencies {
|
|||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
androidTestImplementation(libs.test.junitext)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ data class LoginPasswordState(
|
|||
) {
|
||||
val submitEnabled: Boolean
|
||||
get() = loginAction !is Async.Failure &&
|
||||
((formState.login.isNotEmpty() && formState.password.isNotEmpty()))
|
||||
formState.login.isNotEmpty() &&
|
||||
formState.password.isNotEmpty()
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
<string name="screen_account_provider_form_notice">"Zadajte hľadaný výraz alebo adresu domény."</string>
|
||||
<string name="screen_account_provider_form_subtitle">"Vyhľadať spoločnosť, komunitu alebo súkromný server."</string>
|
||||
<string name="screen_account_provider_form_title">"Nájsť poskytovateľa účtu"</string>
|
||||
<string name="screen_account_provider_signin_subtitle">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
|
||||
<string name="screen_account_provider_signin_subtitle">"Tu budú žiť vaše konverzácie — podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
|
||||
<string name="screen_account_provider_signin_title">"Chystáte sa prihlásiť do %s"</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"Tu budú žiť vaše konverzácie — podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
|
||||
<string name="screen_account_provider_signup_title">"Chystáte sa vytvoriť účet na %s"</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string>
|
||||
<string name="screen_change_account_provider_other">"Iný"</string>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.login.impl.changeserver
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -36,7 +36,7 @@ class ChangeServerPresenterTest {
|
|||
FakeAuthenticationService(),
|
||||
AccountProviderDataSource()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -51,7 +51,7 @@ class ChangeServerPresenterTest {
|
|||
authenticationService,
|
||||
AccountProviderDataSource()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -72,7 +72,7 @@ class ChangeServerPresenterTest {
|
|||
authenticationService,
|
||||
AccountProviderDataSource()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -38,7 +38,7 @@ class OidcPresenterTest {
|
|||
A_OIDC_DATA,
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -53,7 +53,7 @@ class OidcPresenterTest {
|
|||
A_OIDC_DATA,
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -73,7 +73,7 @@ class OidcPresenterTest {
|
|||
authenticationService,
|
||||
)
|
||||
authenticationService.givenOidcCancelError(A_THROWABLE)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -92,7 +92,7 @@ class OidcPresenterTest {
|
|||
A_OIDC_DATA,
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -110,7 +110,7 @@ class OidcPresenterTest {
|
|||
A_OIDC_DATA,
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -129,7 +129,7 @@ class OidcPresenterTest {
|
|||
authenticationService,
|
||||
)
|
||||
authenticationService.givenLoginError(A_THROWABLE)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.login.impl.screens.changeaccountprovider
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -37,7 +37,7 @@ class ChangeAccountProviderPresenterTest {
|
|||
val presenter = ChangeAccountProviderPresenter(
|
||||
changeServerPresenter
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.login.impl.screens.confirmaccountprovider
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -38,7 +38,7 @@ class ConfirmAccountProviderPresenterTest {
|
|||
AccountProviderDataSource(),
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -58,7 +58,7 @@ class ConfirmAccountProviderPresenterTest {
|
|||
authServer,
|
||||
)
|
||||
authServer.givenHomeserver(A_HOMESERVER)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -82,7 +82,7 @@ class ConfirmAccountProviderPresenterTest {
|
|||
authServer,
|
||||
)
|
||||
authServer.givenHomeserver(A_HOMESERVER_OIDC)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -105,7 +105,7 @@ class ConfirmAccountProviderPresenterTest {
|
|||
AccountProviderDataSource(),
|
||||
authServer,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -126,7 +126,7 @@ class ConfirmAccountProviderPresenterTest {
|
|||
AccountProviderDataSource(),
|
||||
authenticationService,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.login.impl.screens.loginpassword
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -45,7 +45,7 @@ class LoginPasswordPresenterTest {
|
|||
accountProviderDataSource,
|
||||
loginUserStory,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -67,7 +67,7 @@ class LoginPasswordPresenterTest {
|
|||
loginUserStory,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -93,7 +93,7 @@ class LoginPasswordPresenterTest {
|
|||
loginUserStory,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
|
||||
|
|
@ -122,7 +122,7 @@ class LoginPasswordPresenterTest {
|
|||
loginUserStory,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -150,7 +150,7 @@ class LoginPasswordPresenterTest {
|
|||
loginUserStory,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.login.impl.screens.searchaccountprovider
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -46,7 +46,7 @@ class SearchAccountProviderPresenterTest {
|
|||
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
|
||||
changeServerPresenter
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -66,7 +66,7 @@ class SearchAccountProviderPresenterTest {
|
|||
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
|
||||
changeServerPresenter
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -90,7 +90,7 @@ class SearchAccountProviderPresenterTest {
|
|||
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
|
||||
changeServerPresenter
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -125,7 +125,7 @@ class SearchAccountProviderPresenterTest {
|
|||
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
|
||||
changeServerPresenter
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -160,7 +160,7 @@ class SearchAccountProviderPresenterTest {
|
|||
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
|
||||
changeServerPresenter
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -46,7 +46,7 @@ class WaitListPresenterTest {
|
|||
authenticationService,
|
||||
loginUserStory,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -68,7 +68,7 @@ class WaitListPresenterTest {
|
|||
authenticationService,
|
||||
loginUserStory,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -97,7 +97,7 @@ class WaitListPresenterTest {
|
|||
authenticationService,
|
||||
loginUserStory,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
|
||||
|
|
|
|||
|
|
@ -48,6 +48,4 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
|
||||
androidTestImplementation(libs.test.junitext)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -34,7 +34,7 @@ class LogoutPreferencePresenterTest {
|
|||
val presenter = DefaultLogoutPreferencePresenter(
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -47,7 +47,7 @@ class LogoutPreferencePresenterTest {
|
|||
val presenter = DefaultLogoutPreferencePresenter(
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -65,7 +65,7 @@ class LogoutPreferencePresenterTest {
|
|||
val presenter = DefaultLogoutPreferencePresenter(
|
||||
matrixClient,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -78,6 +78,5 @@ dependencies {
|
|||
testImplementation(projects.libraries.mediapickers.test)
|
||||
testImplementation(libs.test.mockk)
|
||||
|
||||
androidTestImplementation(libs.test.junitext)
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ fun ForwardMessagesView(
|
|||
return
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) {
|
||||
// TODO toggle selection when multi-selection is enabled
|
||||
state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ import io.element.android.features.messages.impl.media.local.LocalMediaView
|
|||
import io.element.android.features.messages.impl.media.local.MediaInfo
|
||||
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ internal fun AttachmentsBottomSheet(
|
|||
onDismissRequest = { isVisible = false }
|
||||
) {
|
||||
AttachmentSourcePickerMenu(
|
||||
eventSink = state.eventSink,
|
||||
state = state,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
)
|
||||
}
|
||||
|
|
@ -93,7 +93,7 @@ internal fun AttachmentsBottomSheet(
|
|||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
internal fun AttachmentSourcePickerMenu(
|
||||
eventSink: (MessageComposerEvents) -> Unit,
|
||||
state: MessageComposerState,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -102,33 +102,35 @@ internal fun AttachmentSourcePickerMenu(
|
|||
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||
icon = { Icon(Icons.Default.Collections, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||
icon = { Icon(Icons.Default.AttachFile, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||
icon = { Icon(Icons.Default.Videocam, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
eventSink(MessageComposerEvents.PickAttachmentSource.Location)
|
||||
onSendLocationClicked()
|
||||
},
|
||||
icon = { Icon(Icons.Default.LocationOn, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
|
||||
)
|
||||
if (state.canShareLocation) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(MessageComposerEvents.PickAttachmentSource.Location)
|
||||
onSendLocationClicked()
|
||||
},
|
||||
icon = { Icon(Icons.Default.LocationOn, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +138,9 @@ internal fun AttachmentSourcePickerMenu(
|
|||
@Composable
|
||||
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
|
||||
AttachmentSourcePickerMenu(
|
||||
eventSink = {},
|
||||
state = aMessageComposerState(
|
||||
canShareLocation = true,
|
||||
),
|
||||
onSendLocationClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ class MessageComposerPresenter @Inject constructor(
|
|||
mutableStateOf<AttachmentsState>(AttachmentsState.None)
|
||||
}
|
||||
|
||||
val canShareLocation = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
|
||||
}
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
|
||||
handlePickedMedia(attachmentsState, uri, mimeType)
|
||||
}
|
||||
|
|
@ -140,23 +145,23 @@ class MessageComposerPresenter @Inject constructor(
|
|||
)
|
||||
)
|
||||
}
|
||||
MessageComposerEvents.AddAttachment -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.AddAttachment -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = true
|
||||
}
|
||||
MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false
|
||||
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
galleryMediaPicker.launch()
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
filesPicker.launch()
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
cameraPhotoPicker.launch()
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
cameraVideoPicker.launch()
|
||||
}
|
||||
|
|
@ -173,17 +178,12 @@ class MessageComposerPresenter @Inject constructor(
|
|||
hasFocus = hasFocus.value,
|
||||
mode = messageComposerContext.composerMode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation.value,
|
||||
attachmentsState = attachmentsState.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchIfMediaPickerEnabled(action: suspend () -> Unit) = launch {
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendMessage(
|
||||
text: String,
|
||||
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ data class MessageComposerState(
|
|||
val hasFocus: Boolean,
|
||||
val mode: MessageComposerMode,
|
||||
val showAttachmentSourcePicker: Boolean,
|
||||
val canShareLocation: Boolean,
|
||||
val attachmentsState: AttachmentsState,
|
||||
val eventSink: (MessageComposerEvents) -> Unit
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -26,12 +26,21 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
|||
)
|
||||
}
|
||||
|
||||
fun aMessageComposerState() = MessageComposerState(
|
||||
text = "",
|
||||
isFullScreen = false,
|
||||
hasFocus = false,
|
||||
mode = MessageComposerMode.Normal(content = ""),
|
||||
showAttachmentSourcePicker = false,
|
||||
attachmentsState = AttachmentsState.None,
|
||||
eventSink = {}
|
||||
fun aMessageComposerState(
|
||||
text: String = "",
|
||||
isFullScreen: Boolean = false,
|
||||
hasFocus: Boolean = false,
|
||||
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
|
||||
showAttachmentSourcePicker: Boolean = false,
|
||||
canShareLocation: Boolean = true,
|
||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||
) = MessageComposerState(
|
||||
text = text,
|
||||
isFullScreen = isFullScreen,
|
||||
hasFocus = hasFocus,
|
||||
mode = mode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation,
|
||||
attachmentsState = attachmentsState,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val backPaginationEventLimit = 20
|
||||
private const val backPaginationPageSize = 50
|
||||
private const val BACK_PAGINATION_EVENT_LIMIT = 20
|
||||
private const val BACK_PAGINATION_PAGE_SIZE = 50
|
||||
|
||||
class TimelinePresenter @Inject constructor(
|
||||
private val timelineItemsFactory: TimelineItemsFactory,
|
||||
|
|
@ -164,6 +164,6 @@ class TimelinePresenter @Inject constructor(
|
|||
}
|
||||
|
||||
private fun CoroutineScope.paginateBackwards() = launch {
|
||||
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
|
||||
timeline.paginateBackwards(BACK_PAGINATION_EVENT_LIMIT, BACK_PAGINATION_PAGE_SIZE)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ fun TimelineView(
|
|||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun inReplyToClicked(eventId: EventId) {
|
||||
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ 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 com.vanniktech.emoji.Emoji
|
||||
import com.vanniktech.emoji.google.GoogleEmojiProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
|
|
@ -62,7 +61,7 @@ fun EmojiPicker(
|
|||
val emojiProvider = remember { GoogleEmojiProvider() }
|
||||
val categories = remember { emojiProvider.categories }
|
||||
val pagerState = rememberPagerState()
|
||||
Column (modifier) {
|
||||
Column(modifier) {
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
) {
|
||||
|
|
@ -109,7 +108,8 @@ fun EmojiPicker(
|
|||
Text(
|
||||
text = item.unicode,
|
||||
style = ElementTheme.typography.fontHeadingSmRegular,
|
||||
) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import androidx.compose.ui.graphics.Shape
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ 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.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
|
||||
private val CORNER_RADIUS = 8.dp
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ private fun TextContent(
|
|||
.height(reactionEmojiLineHeight.toDp()),
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.materialColors.primary
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -126,7 +127,7 @@ private fun IconContent(
|
|||
) = Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
tint = ElementTheme.materialColors.secondary,
|
||||
modifier = modifier
|
||||
.size(reactionEmojiLineHeight.toDp())
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.zIndex
|
||||
import androidx.constraintlayout.compose.ConstrainScope
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
|
|
@ -291,7 +290,7 @@ private fun TimelineItemEventRowContent(
|
|||
if (event.reactionsState.reactions.isNotEmpty()) {
|
||||
TimelineItemReactions(
|
||||
reactionsState = event.reactionsState,
|
||||
mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start,
|
||||
isOutgoing = event.isMine,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onMoreReactionsClicked = { onMoreReactionsClicked(event) },
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
||||
/**
|
||||
* A flow layout for reactions that will show a collapse/expand button when the layout wraps over a defined number of rows.
|
||||
* It displays an add more button when there are greater than 0 reactions and always displays the reaction and add more button
|
||||
* on the same row (moving them both to a new row if necessary).
|
||||
* @param expandButton The expand button
|
||||
* @param addMoreButton The add more button
|
||||
* @param modifier The modifier to apply to this layout
|
||||
* @param itemSpacing The horizontal spacing between items
|
||||
* @param rowSpacing The vertical spacing between rows
|
||||
* @param expanded Whether the layout should display in expanded or collapsed state
|
||||
* @param rowsBeforeCollapsible The number of rows before the collapse/expand button is shown
|
||||
* @param reactions The reaction buttons
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineItemReactionsLayout(
|
||||
expandButton: @Composable () -> Unit,
|
||||
addMoreButton: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
itemSpacing: Dp = 0.dp,
|
||||
rowSpacing: Dp = 0.dp,
|
||||
expanded: Boolean = false,
|
||||
rowsBeforeCollapsible: Int? = 2,
|
||||
reactions: @Composable () -> Unit,
|
||||
) {
|
||||
SubcomposeLayout(modifier) { constraints ->
|
||||
// Given the placeables and returns a structure representing
|
||||
// how they should wrap on to multiple rows given the constraints max width.
|
||||
fun calculateRows(measurables: List<Placeable>): List<List<Placeable>> {
|
||||
val rows = mutableListOf<List<Placeable>>()
|
||||
var currentRow = mutableListOf<Placeable>()
|
||||
var rowX = 0
|
||||
|
||||
measurables.forEach { placeable ->
|
||||
val horizontalSpacing = if (currentRow.isEmpty()) 0 else itemSpacing.toPx().toInt()
|
||||
// If the current view does not fit on this row bump to the next
|
||||
if (rowX + placeable.width > constraints.maxWidth) {
|
||||
rows.add(currentRow)
|
||||
currentRow = mutableListOf()
|
||||
rowX = 0
|
||||
}
|
||||
rowX += horizontalSpacing + placeable.width
|
||||
currentRow.add(placeable)
|
||||
}
|
||||
// If there are items in the current row remember to append it to the returned value
|
||||
if (currentRow.size > 0) {
|
||||
rows.add(currentRow)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// Used to render the collapsed state, this takes the rows inputted and adds the extra button to the last row,
|
||||
// removing only as many trailing reactions as needed to make space for it.
|
||||
fun replaceTrailingItemsWithButtons(rowsIn: List<List<Placeable>>, expandButton: Placeable, addMoreButton: Placeable): List<List<Placeable>> {
|
||||
val rows = rowsIn.toMutableList()
|
||||
val lastRow = rows.last()
|
||||
val buttonsWidth = expandButton.width + itemSpacing.toPx().toInt() + addMoreButton.width
|
||||
var rowX = 0
|
||||
lastRow.forEachIndexed { i, placeable ->
|
||||
val horizontalSpacing = if (i == 0) 0 else itemSpacing.toPx().toInt()
|
||||
rowX += placeable.width + horizontalSpacing
|
||||
if (rowX > constraints.maxWidth - (buttonsWidth + horizontalSpacing)) {
|
||||
val lastRowWithButton = lastRow.take(i) + listOf(expandButton, addMoreButton)
|
||||
rows[rows.size - 1] = lastRowWithButton
|
||||
return rows
|
||||
}
|
||||
}
|
||||
val lastRowWithButton = lastRow + listOf(expandButton, addMoreButton)
|
||||
rows[rows.size - 1] = lastRowWithButton
|
||||
return rows
|
||||
}
|
||||
|
||||
// To prevent the add more and expand buttons from wrapping on to separate lines.
|
||||
// If there is one item on the last line, it moves the expand button down.
|
||||
fun ensureCollapseAndAddMoreButtonsAreOnTheSameRow(rowsIn: List<List<Placeable>>): List<List<Placeable>> {
|
||||
val lastRow = rowsIn.last().toMutableList()
|
||||
if (lastRow.size != 1) {
|
||||
return rowsIn
|
||||
}
|
||||
val rows = rowsIn.toMutableList()
|
||||
val secondLastRow = rows[rows.size - 2].toMutableList()
|
||||
val expandButtonPlaceable = secondLastRow.removeLast()
|
||||
lastRow.add(0, expandButtonPlaceable)
|
||||
rows[rows.size - 2] = secondLastRow
|
||||
rows[rows.size - 1] = lastRow
|
||||
return rows
|
||||
}
|
||||
|
||||
/// Given a list of rows place them in the layout.
|
||||
fun layoutRows(rows: List<List<Placeable>>): MeasureResult {
|
||||
var width = 0
|
||||
var height = 0
|
||||
val placeables = rows.mapIndexed { i, row ->
|
||||
var rowX = 0
|
||||
var rowHeight = 0
|
||||
val verticalSpacing = if (i == 0) 0 else rowSpacing.toPx().toInt()
|
||||
val rowWithPoints = row.mapIndexed { j, placeable ->
|
||||
val horizontalSpacing = if (j == 0) 0 else itemSpacing.toPx().toInt()
|
||||
val point = IntOffset(rowX + horizontalSpacing, height + verticalSpacing)
|
||||
rowX += placeable.width + horizontalSpacing
|
||||
rowHeight = maxOf(rowHeight, placeable.height)
|
||||
Pair(placeable, point)
|
||||
}
|
||||
height += rowHeight + verticalSpacing
|
||||
width = maxOf(width, rowX)
|
||||
rowWithPoints
|
||||
}.flatten()
|
||||
|
||||
return layout(width = width, height = height) {
|
||||
placeables.forEach {
|
||||
val (placeable, origin) = it
|
||||
placeable.placeRelative(origin.x, origin.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val reactionsPlaceables = subcompose(0, reactions).map { it.measure(constraints) }
|
||||
if (reactionsPlaceables.isEmpty()) {
|
||||
return@SubcomposeLayout layoutRows(listOf())
|
||||
}
|
||||
val addMorePlaceable = subcompose(1, addMoreButton).first().measure(constraints)
|
||||
val expandPlaceable = subcompose(2, expandButton).first().measure(constraints)
|
||||
|
||||
// Calculate the layout of the rows with the reactions button and add more button
|
||||
val reactionsAndAddMore = calculateRows(reactionsPlaceables + listOf(addMorePlaceable))
|
||||
// If we have extended beyond the defined number of rows we are showing the expand/collapse ui
|
||||
if (rowsBeforeCollapsible?.let { reactionsAndAddMore.size > it } == true) {
|
||||
if (expanded) {
|
||||
// Show all subviews with the add more button at the end
|
||||
var reactionsAndButtons = calculateRows(reactionsPlaceables + listOf(expandPlaceable, addMorePlaceable))
|
||||
reactionsAndButtons = ensureCollapseAndAddMoreButtonsAreOnTheSameRow(reactionsAndButtons)
|
||||
layoutRows(reactionsAndButtons)
|
||||
} else {
|
||||
// Truncate to `rowsBeforeCollapsible` number of rows and replace the reactions at the end of the last row with the buttons
|
||||
val collapsedRows = reactionsAndAddMore.take(rowsBeforeCollapsible)
|
||||
val collapsedRowsWithButtons = replaceTrailingItemsWithButtons(collapsedRows, expandPlaceable, addMorePlaceable)
|
||||
layoutRows(collapsedRowsWithButtons)
|
||||
}
|
||||
} else {
|
||||
// Otherwise we are just showing all items without the expand button
|
||||
layoutRows(reactionsAndAddMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun TimelineItemReactionsLayoutPreview() = ElementPreview {
|
||||
TimelineItemReactionsLayout(
|
||||
expanded = false,
|
||||
expandButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
|
||||
),
|
||||
onClick = { },
|
||||
)
|
||||
},
|
||||
addMoreButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = {}
|
||||
)
|
||||
},
|
||||
reactions = {
|
||||
io.element.android.features.messages.impl.timeline.aTimelineItemReactions(count = 18).reactions.forEach {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Reaction(
|
||||
it
|
||||
),
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -19,18 +19,16 @@ package io.element.android.features.messages.impl.timeline.components
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
|
|
@ -38,162 +36,119 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
|
|||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
/**
|
||||
* The maximum number of items that can be displayed before some items will be hidden
|
||||
*
|
||||
* TODO The threshold should be based on the number of rows, rather than items.
|
||||
* Once items would spill onto a third row, they should be hidden.
|
||||
* Note this could be particularly worthwhile to handle reactions that are
|
||||
* longer than a single character (as annotation keys are free text).
|
||||
*/
|
||||
private const val COLLAPSE_ITEMS_THRESHOLD = 8
|
||||
|
||||
@Composable
|
||||
fun TimelineItemReactions(
|
||||
reactionsState: TimelineItemReactions,
|
||||
mainAxisAlignment: FlowMainAxisAlignment,
|
||||
isOutgoing: Boolean,
|
||||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val reactions by remember(reactionsState, expanded) {
|
||||
derivedStateOf {
|
||||
val numToDisplay = if (expanded) {
|
||||
reactionsState.reactions.count()
|
||||
} else {
|
||||
COLLAPSE_ITEMS_THRESHOLD
|
||||
}
|
||||
reactionsState.reactions.take(numToDisplay).toPersistentList()
|
||||
}
|
||||
// In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL.
|
||||
// For RTL languages it should be the opposite.
|
||||
val reactionsLayoutDirection = if (!isOutgoing) LocalLayoutDirection.current
|
||||
else if (LocalLayoutDirection.current == LayoutDirection.Ltr)
|
||||
LayoutDirection.Rtl
|
||||
else
|
||||
LayoutDirection.Ltr
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides reactionsLayoutDirection) {
|
||||
TimelineItemReactionsView(
|
||||
modifier = modifier,
|
||||
reactions = reactionsState.reactions,
|
||||
expanded = expanded,
|
||||
onReactionClick = onReactionClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onToggleExpandClick = { expanded = !expanded },
|
||||
)
|
||||
}
|
||||
|
||||
val expandableState by remember {
|
||||
derivedStateOf {
|
||||
if (expanded) {
|
||||
ExpandableState.Expanded
|
||||
} else {
|
||||
val hiddenItems = reactionsState.reactions.count() - reactions.count()
|
||||
if (hiddenItems > 0) {
|
||||
ExpandableState.Collapsed(hidden = hiddenItems)
|
||||
} else {
|
||||
ExpandableState.None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TimelineItemReactionsView(
|
||||
modifier = modifier,
|
||||
reactions = reactions,
|
||||
expandableState = expandableState,
|
||||
mainAxisAlignment = mainAxisAlignment,
|
||||
onReactionClick = onReactionClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onExpandClick = { expanded = true },
|
||||
onCollapseClick = { expanded = false }
|
||||
)
|
||||
}
|
||||
|
||||
private sealed class ExpandableState {
|
||||
object None: ExpandableState()
|
||||
data class Collapsed(val hidden: Int): ExpandableState()
|
||||
object Expanded : ExpandableState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineItemReactionsView(
|
||||
reactions: ImmutableList<AggregatedReaction>,
|
||||
expandableState: ExpandableState,
|
||||
mainAxisAlignment: FlowMainAxisAlignment,
|
||||
expanded: Boolean,
|
||||
onReactionClick: (emoji: String) -> Unit,
|
||||
onMoreReactionsClick: () -> Unit,
|
||||
onExpandClick: () -> Unit,
|
||||
onCollapseClick: () -> Unit,
|
||||
onToggleExpandClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) =
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
mainAxisSpacing = 4.dp,
|
||||
crossAxisSpacing = 4.dp,
|
||||
mainAxisAlignment = mainAxisAlignment,
|
||||
) {
|
||||
) = TimelineItemReactionsLayout(
|
||||
modifier = modifier,
|
||||
itemSpacing = 4.dp,
|
||||
rowSpacing = 4.dp,
|
||||
expanded = expanded,
|
||||
expandButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = stringResource(id = if (expanded) R.string.screen_room_reactions_show_less else R.string.screen_room_reactions_show_more)
|
||||
),
|
||||
onClick = onToggleExpandClick,
|
||||
)
|
||||
},
|
||||
addMoreButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = onMoreReactionsClick
|
||||
)
|
||||
},
|
||||
reactions = {
|
||||
reactions.forEach { reaction ->
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Reaction(reaction = reaction),
|
||||
onClick = { onReactionClick(reaction.key) }
|
||||
)
|
||||
}
|
||||
when (expandableState) {
|
||||
ExpandableState.Expanded ->
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
|
||||
),
|
||||
onClick = onCollapseClick,
|
||||
)
|
||||
is ExpandableState.Collapsed -> {
|
||||
val hidden = expandableState.hidden
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = pluralStringResource(id = R.plurals.screen_room_timeline_more_reactions, hidden, hidden)
|
||||
),
|
||||
onClick = onExpandClick,
|
||||
)
|
||||
}
|
||||
ExpandableState.None -> {
|
||||
// No expand or collapse action available
|
||||
}
|
||||
}
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = onMoreReactionsClick
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 1).reactions,
|
||||
expandableState = ExpandableState.None,
|
||||
reactions = aTimelineItemReactions(count = 1).reactions
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewCollapsedPreview() = ElementPreview {
|
||||
fun TimelineItemReactionsViewFewPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 3).reactions,
|
||||
expandableState = ExpandableState.Collapsed(hidden = 7),
|
||||
reactions = aTimelineItemReactions(count = 3).reactions
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewExpandedPreview() = ElementPreview {
|
||||
fun TimelineItemReactionsViewIncomingPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 10).reactions,
|
||||
expandableState = ExpandableState.Expanded,
|
||||
reactions = aTimelineItemReactions(count = 18).reactions
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 18).reactions,
|
||||
isOutgoing = true
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(
|
||||
reactions: ImmutableList<AggregatedReaction>,
|
||||
expandableState: ExpandableState
|
||||
isOutgoing: Boolean = false
|
||||
) {
|
||||
TimelineItemReactionsView(
|
||||
reactions = reactions,
|
||||
expandableState = expandableState,
|
||||
mainAxisAlignment = FlowMainAxisAlignment.Center,
|
||||
onReactionClick = {},
|
||||
onMoreReactionsClick = {},
|
||||
onExpandClick = {},
|
||||
onCollapseClick = {}
|
||||
TimelineItemReactions(
|
||||
reactionsState = TimelineItemReactions(
|
||||
reactions
|
||||
),
|
||||
isOutgoing = isOutgoing,
|
||||
onReactionClicked = {},
|
||||
onMoreReactionsClicked = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
|
|||
fun ExtraPadding.getStr(fontSize: TextUnit): String {
|
||||
if (nbChars == 0) return ""
|
||||
val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
|
||||
val nbOfSpaces = ((timestampFontSize.value / fontSize.value) * nbChars).toInt() + 1
|
||||
val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).toInt() + 1
|
||||
// A space and some unbreakable spaces
|
||||
return " " + "\u00A0".repeat(nbOfSpaces)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
|
||||
@Composable
|
||||
fun TimelineItemEncryptedView(
|
||||
content: TimelineItemEncryptedContent,
|
||||
@Suppress("UNUSED_PARAMETER") content: TimelineItemEncryptedContent,
|
||||
extraPadding: ExtraPadding,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
|
||||
@Composable
|
||||
fun TimelineItemRedactedView(
|
||||
content: TimelineItemRedactedContent,
|
||||
@Suppress("UNUSED_PARAMETER") content: TimelineItemRedactedContent,
|
||||
extraPadding: ExtraPadding,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
|
||||
@Composable
|
||||
fun TimelineItemUnknownView(
|
||||
content: TimelineItemUnknownContent,
|
||||
@Suppress("UNUSED_PARAMETER") content: TimelineItemUnknownContent,
|
||||
extraPadding: ExtraPadding,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ package io.element.android.features.messages.impl.timeline.components.event
|
|||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
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.Icon
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ import org.jsoup.nodes.Element
|
|||
import org.jsoup.nodes.Node
|
||||
import org.jsoup.nodes.TextNode
|
||||
|
||||
private const val chipId = "chip"
|
||||
private const val CHIP_ID = "chip"
|
||||
|
||||
@Composable
|
||||
fun HtmlDocument(
|
||||
|
|
@ -351,7 +351,7 @@ private fun HtmlMxReply(
|
|||
Surface(
|
||||
modifier = modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.offset(x = -(8.dp)),
|
||||
.offset(x = (-8).dp),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
shape = shape,
|
||||
) {
|
||||
|
|
@ -544,17 +544,27 @@ private fun AnnotatedString.Builder.appendLink(link: Element) {
|
|||
pop()
|
||||
}
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
appendInlineContent(chipId, link.ownText())
|
||||
safeAppendInlineContent(CHIP_ID, link.ownText())
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
appendInlineContent(chipId, link.ownText())
|
||||
safeAppendInlineContent(CHIP_ID, link.ownText())
|
||||
}
|
||||
is PermalinkData.UserLink -> {
|
||||
appendInlineContent(chipId, link.ownText())
|
||||
safeAppendInlineContent(CHIP_ID, link.ownText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun AnnotatedString.Builder.safeAppendInlineContent(chipId: String, ownText: String) {
|
||||
if (ownText.isEmpty()) {
|
||||
// alternateText cannot be empty and default parameter value is private,
|
||||
// so just omit the second param here.
|
||||
appendInlineContent(chipId)
|
||||
} else {
|
||||
appendInlineContent(chipId, ownText)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlText(
|
||||
text: AnnotatedString,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -165,6 +164,7 @@ internal fun RetrySendMessageMenuPreviewDark(@PreviewParameter(RetrySendMenuStat
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ContentToPreview(state: RetrySendMenuState) {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ 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.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import javax.inject.Inject
|
|||
|
||||
class TimelineItemContentFailedToParseMessageFactory @Inject constructor() {
|
||||
|
||||
fun create(failedToParseMessageLike: FailedToParseMessageLikeContent): TimelineItemEventContent {
|
||||
fun create(@Suppress("UNUSED_PARAMETER") failedToParseMessageLike: FailedToParseMessageLikeContent): TimelineItemEventContent {
|
||||
return TimelineItemUnknownContent
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import javax.inject.Inject
|
|||
|
||||
class TimelineItemContentFailedToParseStateFactory @Inject constructor() {
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun create(failedToParseState: FailedToParseStateContent): TimelineItemEventContent {
|
||||
return TimelineItemUnknownContent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import javax.inject.Inject
|
|||
|
||||
class TimelineItemContentRedactedFactory @Inject constructor() {
|
||||
|
||||
fun create(content: RedactedContent): TimelineItemEventContent {
|
||||
fun create(@Suppress("UNUSED_PARAMETER") content: RedactedContent): TimelineItemEventContent {
|
||||
return TimelineItemRedactedContent
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import javax.inject.Inject
|
|||
|
||||
class TimelineItemContentStickerFactory @Inject constructor() {
|
||||
|
||||
fun create(content: StickerContent): TimelineItemEventContent {
|
||||
fun create(@Suppress("UNUSED_PARAMETER") content: StickerContent): TimelineItemEventContent {
|
||||
return TimelineItemUnknownContent
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<item quantity="other">"%1$d changements dans la conversation"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_more_reactions">
|
||||
<item quantity="one"></item>
|
||||
<item quantity="one">"1 de plus"</item>
|
||||
<item quantity="other">"%1$d de plus"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_attachment_source_camera">"Appareil photo"</string>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue