Merge branch 'develop' into feature-oled-black

This commit is contained in:
Timur Gilfanov 2026-03-30 11:08:53 +04:00 committed by GitHub
commit d0dcbab750
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1505 changed files with 14143 additions and 9545 deletions

View file

@ -9,12 +9,15 @@
package io.element.android.libraries.androidutils.ui
import android.os.Build
import android.os.Bundle
import android.os.ResultReceiver
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowInsets
import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlin.coroutines.resume
fun View.hideKeyboard() {
@ -22,6 +25,32 @@ fun View.hideKeyboard() {
imm?.hideSoftInputFromWindow(windowToken, 0)
}
suspend fun View.hideKeyboardAndAwaitAnimation() {
val imm = context?.getSystemService<InputMethodManager>()
val mutex = Mutex()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setOnApplyWindowInsetsListener { view, insets ->
if (!insets.isVisible(WindowInsets.Type.ime())) {
mutex.unlock()
}
insets
}
imm?.hideSoftInputFromWindow(windowToken, 0)
} else {
@Suppress("DEPRECATION")
imm?.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN ||
resultCode == InputMethodManager.RESULT_HIDDEN) {
mutex.unlock()
}
}
})
}
mutex.lock()
}
fun View.showKeyboard(andRequestFocus: Boolean = false) {
if (andRequestFocus) {
requestFocus()

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Не найдено совместимое приложение для обработки этого действия."</string>
<string name="error_no_compatible_app_found">"Не найдено приложение для выполнения этого действия."</string>
</resources>

View file

@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.children.ChildEntry
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.combined.plus
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
@ -27,6 +26,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
import io.element.android.libraries.architecture.appyx.SafeChildren
import io.element.android.libraries.architecture.overlay.Overlay
/**
@ -66,9 +66,9 @@ inline fun <reified NavTarget : Any> BaseFlowNode<NavTarget>.BackstackView(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
),
) {
Children(
modifier = modifier,
SafeChildren(
navModel = backstack,
modifier = modifier,
transitionHandler = transitionHandler,
)
}
@ -78,9 +78,9 @@ inline fun <reified NavTarget : Any> BaseFlowNode<NavTarget>.OverlayView(
modifier: Modifier = Modifier,
transitionHandler: TransitionHandler<NavTarget, BackStack.State> = rememberBackstackFader(),
) {
Children(
modifier = modifier,
SafeChildren(
navModel = overlay,
modifier = modifier,
transitionHandler = transitionHandler,
)
}

View file

@ -0,0 +1,154 @@
package io.element.android.libraries.architecture.appyx
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.currentCompositeKeyHashCode
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.bumble.appyx.core.integration.NodeFactory
import com.bumble.appyx.core.integrationpoint.IntegrationPoint
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.NavElement
import com.bumble.appyx.core.navigation.NavKey
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.build
import com.bumble.appyx.core.state.SavedStateMap
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectory
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
import timber.log.Timber
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
/**
* Contains the last captured navigation state in a human-readable format, which can be attached to crash reports to help
* with debugging `TransactionTooLargeException` crashes.
*/
var lastCapturedNavState: String = "No nav state captured yet"
private data class NodeEntry(
val navKey: Any?,
val children: List<NodeEntry> = emptyList()
) {
override fun toString(): String {
val key = navKey ?: return ""
return buildString {
append(key.javaClass.name)
if (children.isNotEmpty()) {
append("=[")
append(children.joinToString(", "))
append("]")
}
}
}
}
@Suppress("UNCHECKED_CAST")
private fun Map<String, Any?>.buildNavStateMap(): List<NodeEntry> {
val children = this["ChildrenState"] as? Map<NavKey<*>, Map<String, Any?>> ?: return emptyList()
return children.entries.map { (key, value) ->
NodeEntry(
navKey = key.navTarget,
children = value.buildNavStateMap()
)
}
}
@Suppress("UNCHECKED_CAST")
private fun Map<String, Any?>.buildNavModel(name: String): List<NodeEntry> {
val navModel = this[name] as? List<NavElement<*, *>> ?: return emptyList()
return navModel.map {
NodeEntry(
navKey = it.key.navTarget,
children = emptyList()
)
}
}
// Once we have fixed the `TransactionTooLargeException` issues, we should remove this and use the default `NodeHost` implementation
@Suppress("ComposableParamOrder") // detekt complains as 'factory' param isn't a pure lambda
@Composable
fun <N : Node> DebugNavStateNodeHost(
integrationPoint: IntegrationPoint,
modifier: Modifier = Modifier,
customisations: NodeCustomisationDirectory = remember { NodeCustomisationDirectoryImpl() },
factory: NodeFactory<N>
) {
val node by rememberNode(factory, "AppyxMainNode", customisations, integrationPoint)
DisposableEffect(node) {
onDispose { node.updateLifecycleState(Lifecycle.State.DESTROYED) }
}
node.Compose(modifier = modifier)
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
node.updateLifecycleState(lifecycle.currentState)
val observer = LifecycleEventObserver { source, _ ->
node.updateLifecycleState(source.lifecycle.currentState)
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
}
@Composable
private fun <N : Node> rememberNode(
factory: NodeFactory<N>,
key: String,
customisations: NodeCustomisationDirectory,
integrationPoint: IntegrationPoint,
): State<N> {
fun createNode(savedStateMap: SavedStateMap?): N =
factory
.create(
buildContext = BuildContext.root(
savedStateMap = savedStateMap,
customisations = customisations
),
)
.apply { this.integrationPoint = integrationPoint }
.build()
// This is deprecated because using the custom key would not make this unique, but we work around that by using the currentCompositeKeyHashCode
// as part of the key, which should be unique for each call site of rememberNode.
@Suppress("DEPRECATION")
return rememberSaveable(
inputs = arrayOf(),
key = "$key:$currentCompositeKeyHashCode",
stateSaver = mapSaver(
save = { node ->
val result = node.saveInstanceState(this)
// We want to capture the nav state in a format that's easier to read and understand in crash reports, so we build a custom map for that.
val copy = result.toMutableMap()
copy["ChildrenState"] = copy.buildNavStateMap()
val navModelKey = "NavModel"
if (copy.contains(navModelKey)) {
copy[navModelKey] = copy.buildNavModel(navModelKey)
}
val permanentNavModelKey = "com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel"
if (copy.contains(permanentNavModelKey)) {
copy[permanentNavModelKey] =
copy.buildNavModel(permanentNavModelKey)
}
Timber.d("Saving nav state: $copy")
// Store the last nav state in a global variable so that it can be attached to crash reports if the app crashes before the next save happens.
lastCapturedNavState = copy.toString()
result
},
restore = { state -> createNode(savedStateMap = state) },
),
) {
mutableStateOf(createNode(savedStateMap = null))
}
}

View file

@ -0,0 +1,267 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.architecture.appyx
import android.annotation.SuppressLint
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import com.bumble.appyx.core.composable.Child
import com.bumble.appyx.core.composable.ChildRenderer
import com.bumble.appyx.core.composable.ChildTransitionScope
import com.bumble.appyx.core.navigation.NavKey
import com.bumble.appyx.core.navigation.NavModel
import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionBounds
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
import com.bumble.appyx.core.navigation.transition.TransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionParams
import com.bumble.appyx.core.node.LocalMovableContentMap
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import com.bumble.appyx.core.node.LocalSharedElementScope
import com.bumble.appyx.core.node.ParentNode
import io.element.android.libraries.core.coroutine.withPreviousValue
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import timber.log.Timber
import kotlin.reflect.KClass
//////////////////////////////////////////////////////////////////////////////////////////////////////////
// All the components in this file come from Appyx, and they've been modified to fix an issue with
// the saved state. The parts that are modified are marked.
//////////////////////////////////////////////////////////////////////////////////////////////////////////
@Immutable
class SafeChildrenTransitionScope<T : Any, S>(
val transitionHandler: TransitionHandler<T, S>,
val transitionParams: TransitionParams,
val navModel: NavModel<T, S>
) {
@Composable
inline fun <reified V : T> ParentNode<T>.children(
noinline block: @Composable ChildTransitionScope<S>.(
child: ChildRenderer,
transitionDescriptor: TransitionDescriptor<T, S>
) -> Unit,
) {
safeChildren(V::class, block)
}
@Composable
inline fun <reified V : T> ParentNode<T>.children(
noinline block: @Composable ChildTransitionScope<S>.(child: ChildRenderer) -> Unit,
) {
safeChildren(V::class, block)
}
@Composable
@SuppressLint("ComposableNaming")
fun ParentNode<T>.safeChildren(
clazz: KClass<out T>,
block: @Composable ChildTransitionScope<S>.(ChildRenderer) -> Unit,
) {
_safeChildren(clazz) { scope, child, _ ->
scope.block(child)
}
}
@Composable
@SuppressLint("ComposableNaming")
fun ParentNode<T>.safeChildren(
clazz: KClass<out T>,
block: @Composable ChildTransitionScope<S>.(
ChildRenderer,
TransitionDescriptor<T, S>
) -> Unit,
) {
_safeChildren(clazz) { scope, child, descriptor ->
scope.block(
child,
descriptor,
)
}
}
@SuppressLint("ComposableNaming")
@Composable
private fun ParentNode<T>._safeChildren(
clazz: KClass<out T>,
block: @Composable (
transitionScope: ChildTransitionScope<S>,
child: ChildRenderer,
transitionDescriptor: TransitionDescriptor<T, S>
) -> Unit
) {
val saveableStateHolder = rememberSaveableStateHolder()
val disposedNavKeys = remember { mutableSetOf<NavKey<T>>() }
LaunchedEffect(navModel) {
navModel
.removedElementKeys()
.map { list ->
list.filter { clazz.isInstance(it.navTarget) }
}
////////// MODIFIED ////////////
.filter { it.isNotEmpty() }
.collect { deletedKeys ->
deletedKeys.forEach { navKey ->
// Wait for the NavKey to be disposed before removing its key from saveableStateHolder:
// Otherwise, the child SaveableStateRegistry will be removed but not the `SavedState`, which will accumulate
// and may cause TransactionTooLargeExceptions
while (!disposedNavKeys.contains(navKey)) {
delay(10)
}
disposedNavKeys.remove(navKey)
Timber.v("Removed NavKey ${navKey} from saveableStateHolder. NavTarget: ${navKey.navTarget}")
saveableStateHolder.removeState(navKey)
}
}
////////// END OF MODIFIED ////////////
}
val screenStateFlow = remember {
this@SafeChildrenTransitionScope
.navModel
.screenState
}
val children by screenStateFlow.collectAsState()
children
.onScreen
.filter { clazz.isInstance(it.key.navTarget) }
.forEach { navElement ->
key(navElement.key.id) {
CompositionLocalProvider(
LocalNodeTargetVisibility provides
children.onScreenWithVisibleTargetState.contains(navElement)
) {
Child(
navElement,
saveableStateHolder,
transitionParams,
transitionHandler,
block
)
////////// MODIFIED ////////////
DisposableEffect(navElement.key) {
onDispose {
Timber.v("Disposed NavKey ${navElement.key}. NavTarget: ${navElement.key.navTarget}")
disposedNavKeys.add(navElement.key)
}
}
////////// END OF MODIFIED ////////////
}
}
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
inline fun <reified NavTarget : Any, State> ParentNode<NavTarget>.SafeChildren(
navModel: NavModel<NavTarget, State>,
modifier: Modifier = Modifier,
transitionHandler: TransitionHandler<NavTarget, State> = remember { JumpToEndTransitionHandler() },
withSharedElementTransition: Boolean = false,
withMovableContent: Boolean = false,
noinline block: @Composable SafeChildrenTransitionScope<NavTarget, State>.() -> Unit = {
children<NavTarget> { child ->
child()
}
}
) {
val density = LocalDensity.current.density
var transitionBounds by remember { mutableStateOf(IntSize(0, 0)) }
val transitionParams by remember(transitionBounds) {
derivedStateOf {
TransitionParams(
bounds = TransitionBounds(
width = Dp(transitionBounds.width / density),
height = Dp(transitionBounds.height / density)
)
)
}
}
if (withSharedElementTransition) {
SharedTransitionLayout(modifier = modifier
.onSizeChanged {
transitionBounds = it
}
) {
CompositionLocalProvider(
/** LocalSharedElementScope will be consumed by children UI to apply shareElement modifier */
LocalSharedElementScope provides this,
LocalMovableContentMap provides if (withMovableContent) mutableMapOf() else null
) {
block(
SafeChildrenTransitionScope(
transitionHandler = transitionHandler,
transitionParams = transitionParams,
navModel = navModel
)
)
}
}
} else {
Box(modifier = modifier
.onSizeChanged {
transitionBounds = it
}
) {
CompositionLocalProvider(
/** If sharedElement is not supported for this Node - provide null otherwise children
* can consume ascendant's LocalSharedElementScope */
LocalSharedElementScope provides null,
LocalMovableContentMap provides if (withMovableContent) mutableMapOf() else null
) {
block(
SafeChildrenTransitionScope(
transitionHandler = transitionHandler,
transitionParams = transitionParams,
navModel = navModel
)
)
}
}
}
}
internal fun <T: Any, S> NavModel<T, S>.removedElementKeys(): Flow<List<NavKey<T>>> {
return this.elements.withPreviousValue()
.map { (previous, current) ->
val previousKeys = previous?.map { it.key }.orEmpty()
val currentKeys = current.map { it.key }
previousKeys.filter { element ->
!currentKeys.contains(element)
}
}
}

View file

@ -39,10 +39,17 @@ class DefaultAudioFocus(
AudioManager.AUDIOFOCUS_GAIN -> {
// Do nothing
}
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS -> {
// Permanent focus loss (e.g., phone call) — always stop/pause.
onFocusLost()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
onFocusLost()
// For recording, ignore transient focus losses (e.g., notification sounds).
// The AudioRecord API keeps capturing regardless.
if (requester != AudioFocusRequester.RecordVoiceMessage) {
onFocusLost()
}
}
}
}
@ -51,7 +58,12 @@ class DefaultAudioFocus(
val audioAttributes = AudioAttributes.Builder()
.setUsage(requester.toAudioUsage())
.build()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
val focusGain = if (requester == AudioFocusRequester.RecordVoiceMessage) {
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
} else {
AudioManager.AUDIOFOCUS_GAIN
}
val request = AudioFocusRequest.Builder(focusGain)
.setAudioAttributes(audioAttributes)
.setOnAudioFocusChangeListener(listener)
.setWillPauseWhenDucked(requester.willPausedWhenDucked())

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fa16f659aa3e7d05fa03a51d52faddc0c40c3ab52231687f8c6c8a4ba81ff6f0
size 219813
oid sha256:37f6acca46890e98087ece62e2716fa60791479fab02999406050517e3b79307
size 240187

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:72fb457dc50bf1a2261502fc1da15c01ab415344e9070354d38dc7b74234d790
size 232095
oid sha256:a2de5e6d24dcbe0baa75a69485f5a308466fa599625bcbdb0cb96e9bc5a1b708
size 253233

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:24cfe760717881ee71f36fae1fb201e74b2c32a2f9a5aef71ef21dab69ea5366
size 233212
oid sha256:ae1cb46d82acbb23cc172f41e20a41bbe88c350ab53c20e5b2a91f2c16590fbf
size 254525

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c846cd10b83361c368bdbb31ed6220cc22693c3cbf52791fb369841af1e9ea48
size 327701
oid sha256:a9334d37f010d4e520b11dbd16d664fbb4413497d371dc8b0af0157faf870451
size 323086

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:05b35fedbd53dec2cc5c4c211a8db1a56055963de69425ddae2cab5aff7e3e75
size 325750
oid sha256:9e016ef5e07de6f6e86e5e6104d78502f5ee15ecb39d1533f020cf94ac087603
size 320821

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8d98e64eda5d6333067ccc599e99636f618331397207bb7534595e2756edb75e
size 309312
oid sha256:93df69ddd7a1571abcb868495edb9914b5d832c1e55f1520a1c04a71de59577f
size 302213

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5ccbf1234065b182939f001eb65eca0a62adae41a2d91ef0307d27b059407178
size 309084
oid sha256:b1eb3a0283e42d2e2d1083c95fd2bbd2e338fcc5f318c07386f04cfb97e6fed7
size 301963

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a699170cabca6fb912d034a588b45961485afe6ef6d2c24f0ab79f10ae00c168
size 85629
oid sha256:8a8a9b6e61758a40d01028a4edb4a4d21b845b83b3e0793ed0934e48f3d9eea0
size 94637

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dd4b2a40fcf02d6db29cb0bc371d93236b4a0be6d4446bab86358692cddb53f5
size 91692
oid sha256:7f29d225df71587fefe07ec8739b84f1a0469786c6b1d6778da0bad33d19574e
size 101183

File diff suppressed because one or more lines are too long

View file

@ -123,6 +123,7 @@ private fun getSemanticColors(): ImmutableMap<String, Color> {
"bgBadgeAccent" to bgBadgeAccent,
"bgBadgeDefault" to bgBadgeDefault,
"bgBadgeInfo" to bgBadgeInfo,
"bgBadgePrimary" to bgBadgePrimary,
"bgCanvasDefault" to bgCanvasDefault,
"bgCanvasDefaultLevel1" to bgCanvasDefaultLevel1,
"bgCanvasDisabled" to bgCanvasDisabled,
@ -156,12 +157,10 @@ private fun getSemanticColors(): ImmutableMap<String, Color> {
"gradientActionStop2" to gradientActionStop2,
"gradientActionStop3" to gradientActionStop3,
"gradientActionStop4" to gradientActionStop4,
"gradientCriticalStop1" to gradientCriticalStop1,
"gradientCriticalStop2" to gradientCriticalStop2,
"gradientInfoStop1" to gradientInfoStop1,
"gradientInfoStop2" to gradientInfoStop2,
"gradientInfoStop3" to gradientInfoStop3,
"gradientInfoStop4" to gradientInfoStop4,
"gradientInfoStop5" to gradientInfoStop5,
"gradientInfoStop6" to gradientInfoStop6,
"gradientSubtleStop1" to gradientSubtleStop1,
"gradientSubtleStop2" to gradientSubtleStop2,
"gradientSubtleStop3" to gradientSubtleStop3,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
@ -25,6 +25,9 @@ object CompoundIcons {
@Composable fun Admin(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_admin)
}
@Composable fun AdvancedSettings(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_advanced_settings)
}
@Composable fun ArrowDown(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_arrow_down)
}
@ -64,6 +67,9 @@ object CompoundIcons {
@Composable fun Bold(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_bold)
}
@Composable fun Bug(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_bug)
}
@Composable fun Calendar(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_calendar)
}
@ -142,6 +148,9 @@ object CompoundIcons {
@Composable fun Delete(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_delete)
}
@Composable fun DevicePasskey(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_device_passkey)
}
@Composable fun Devices(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_devices)
}
@ -220,6 +229,9 @@ object CompoundIcons {
@Composable fun Filter(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_filter)
}
@Composable fun Folder(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_folder)
}
@Composable fun Forward(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_forward)
}
@ -460,6 +472,9 @@ object CompoundIcons {
@Composable fun RaisedHandSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_raised_hand_solid)
}
@Composable fun ReOrder(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_re_order)
}
@Composable fun Reaction(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_reaction)
}
@ -478,9 +493,18 @@ object CompoundIcons {
@Composable fun Room(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_room)
}
@Composable fun RotateLeft(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_rotate_left)
}
@Composable fun RotateRight(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_rotate_right)
}
@Composable fun Search(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_search)
}
@Composable fun Section(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_section)
}
@Composable fun Send(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_send)
}
@ -535,6 +559,12 @@ object CompoundIcons {
@Composable fun Sticker(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_sticker)
}
@Composable fun Stop(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_stop)
}
@Composable fun StopSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_stop_solid)
}
@Composable fun Strikethrough(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_strikethrough)
}
@ -550,6 +580,9 @@ object CompoundIcons {
@Composable fun TextFormatting(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_text_formatting)
}
@Composable fun Theme(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_theme)
}
@Composable fun Threads(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_threads)
}
@ -559,6 +592,12 @@ object CompoundIcons {
@Composable fun Time(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_time)
}
@Composable fun Translate(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_translate)
}
@Composable fun Tree(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_tree)
}
@Composable fun Underline(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_underline)
}
@ -607,6 +646,9 @@ object CompoundIcons {
@Composable fun VideoCallOffSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_video_call_off_solid)
}
@Composable fun VideoCallOutgoingSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_video_call_outgoing_solid)
}
@Composable fun VideoCallSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_video_call_solid)
}
@ -619,6 +661,15 @@ object CompoundIcons {
@Composable fun VoiceCall(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call)
}
@Composable fun VoiceCallDeclinedSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_declined_solid)
}
@Composable fun VoiceCallMissedSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_missed_solid)
}
@Composable fun VoiceCallOutgoingSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_outgoing_solid)
}
@Composable fun VoiceCallSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_solid)
}
@ -643,9 +694,16 @@ object CompoundIcons {
@Composable fun Windows(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_windows)
}
@Composable fun ZoomIn(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_zoom_in)
}
@Composable fun ZoomOut(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_zoom_out)
}
val all @Composable get() = persistentListOf<ImageVector>(
Admin(),
AdvancedSettings(),
ArrowDown(),
ArrowLeft(),
ArrowRight(),
@ -659,6 +717,7 @@ object CompoundIcons {
BackspaceSolid(),
Block(),
Bold(),
Bug(),
Calendar(),
Chart(),
Chat(),
@ -685,6 +744,7 @@ object CompoundIcons {
Copy(),
DarkMode(),
Delete(),
DevicePasskey(),
Devices(),
DialPad(),
Document(),
@ -711,6 +771,7 @@ object CompoundIcons {
FileError(),
Files(),
Filter(),
Folder(),
Forward(),
FullScreen(),
Grid(),
@ -791,13 +852,17 @@ object CompoundIcons {
QrCode(),
Quote(),
RaisedHandSolid(),
ReOrder(),
Reaction(),
ReactionAdd(),
ReactionSolid(),
Reply(),
Restart(),
Room(),
RotateLeft(),
RotateRight(),
Search(),
Section(),
Send(),
SendSolid(),
Settings(),
@ -816,14 +881,19 @@ object CompoundIcons {
Spotlight(),
SpotlightView(),
Sticker(),
Stop(),
StopSolid(),
Strikethrough(),
SwitchCameraSolid(),
TakePhoto(),
TakePhotoSolid(),
TextFormatting(),
Theme(),
Threads(),
ThreadsSolid(),
Time(),
Translate(),
Tree(),
Underline(),
Unknown(),
UnknownSolid(),
@ -840,10 +910,14 @@ object CompoundIcons {
VideoCallMissedSolid(),
VideoCallOff(),
VideoCallOffSolid(),
VideoCallOutgoingSolid(),
VideoCallSolid(),
VisibilityOff(),
VisibilityOn(),
VoiceCall(),
VoiceCallDeclinedSolid(),
VoiceCallMissedSolid(),
VoiceCallOutgoingSolid(),
VoiceCallSolid(),
VolumeOff(),
VolumeOffSolid(),
@ -852,10 +926,13 @@ object CompoundIcons {
Warning(),
WebBrowser(),
Windows(),
ZoomIn(),
ZoomOut(),
)
val allResIds get() = persistentListOf(
R.drawable.ic_compound_admin,
R.drawable.ic_compound_advanced_settings,
R.drawable.ic_compound_arrow_down,
R.drawable.ic_compound_arrow_left,
R.drawable.ic_compound_arrow_right,
@ -869,6 +946,7 @@ object CompoundIcons {
R.drawable.ic_compound_backspace_solid,
R.drawable.ic_compound_block,
R.drawable.ic_compound_bold,
R.drawable.ic_compound_bug,
R.drawable.ic_compound_calendar,
R.drawable.ic_compound_chart,
R.drawable.ic_compound_chat,
@ -895,6 +973,7 @@ object CompoundIcons {
R.drawable.ic_compound_copy,
R.drawable.ic_compound_dark_mode,
R.drawable.ic_compound_delete,
R.drawable.ic_compound_device_passkey,
R.drawable.ic_compound_devices,
R.drawable.ic_compound_dial_pad,
R.drawable.ic_compound_document,
@ -921,6 +1000,7 @@ object CompoundIcons {
R.drawable.ic_compound_file_error,
R.drawable.ic_compound_files,
R.drawable.ic_compound_filter,
R.drawable.ic_compound_folder,
R.drawable.ic_compound_forward,
R.drawable.ic_compound_full_screen,
R.drawable.ic_compound_grid,
@ -1001,13 +1081,17 @@ object CompoundIcons {
R.drawable.ic_compound_qr_code,
R.drawable.ic_compound_quote,
R.drawable.ic_compound_raised_hand_solid,
R.drawable.ic_compound_re_order,
R.drawable.ic_compound_reaction,
R.drawable.ic_compound_reaction_add,
R.drawable.ic_compound_reaction_solid,
R.drawable.ic_compound_reply,
R.drawable.ic_compound_restart,
R.drawable.ic_compound_room,
R.drawable.ic_compound_rotate_left,
R.drawable.ic_compound_rotate_right,
R.drawable.ic_compound_search,
R.drawable.ic_compound_section,
R.drawable.ic_compound_send,
R.drawable.ic_compound_send_solid,
R.drawable.ic_compound_settings,
@ -1026,14 +1110,19 @@ object CompoundIcons {
R.drawable.ic_compound_spotlight,
R.drawable.ic_compound_spotlight_view,
R.drawable.ic_compound_sticker,
R.drawable.ic_compound_stop,
R.drawable.ic_compound_stop_solid,
R.drawable.ic_compound_strikethrough,
R.drawable.ic_compound_switch_camera_solid,
R.drawable.ic_compound_take_photo,
R.drawable.ic_compound_take_photo_solid,
R.drawable.ic_compound_text_formatting,
R.drawable.ic_compound_theme,
R.drawable.ic_compound_threads,
R.drawable.ic_compound_threads_solid,
R.drawable.ic_compound_time,
R.drawable.ic_compound_translate,
R.drawable.ic_compound_tree,
R.drawable.ic_compound_underline,
R.drawable.ic_compound_unknown,
R.drawable.ic_compound_unknown_solid,
@ -1050,10 +1139,14 @@ object CompoundIcons {
R.drawable.ic_compound_video_call_missed_solid,
R.drawable.ic_compound_video_call_off,
R.drawable.ic_compound_video_call_off_solid,
R.drawable.ic_compound_video_call_outgoing_solid,
R.drawable.ic_compound_video_call_solid,
R.drawable.ic_compound_visibility_off,
R.drawable.ic_compound_visibility_on,
R.drawable.ic_compound_voice_call,
R.drawable.ic_compound_voice_call_declined_solid,
R.drawable.ic_compound_voice_call_missed_solid,
R.drawable.ic_compound_voice_call_outgoing_solid,
R.drawable.ic_compound_voice_call_solid,
R.drawable.ic_compound_volume_off,
R.drawable.ic_compound_volume_off_solid,
@ -1062,5 +1155,7 @@ object CompoundIcons {
R.drawable.ic_compound_warning,
R.drawable.ic_compound_web_browser,
R.drawable.ic_compound_windows,
R.drawable.ic_compound_zoom_in,
R.drawable.ic_compound_zoom_out,
)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
@ -49,12 +49,12 @@ data class SemanticColors(
val bgActionTertiaryRest: Color,
/** Background colour for tertiary actions. State: Selected */
val bgActionTertiarySelected: Color,
/** Badge accent background colour */
val bgBadgeAccent: Color,
/** Badge default background colour */
val bgBadgeCritical: Color,
val bgBadgeDefault: Color,
/** Badge info background colour */
val bgBadgeInfo: Color,
val bgBadgePrimary: Color,
val bgBadgeSecondary: Color,
/** Default global background for the user interface. Elevation: Default (Level 0) */
val bgCanvasDefault: Color,
/** Default global background for the user interface. Elevation: Level 1. */
@ -121,18 +121,14 @@ data class SemanticColors(
val gradientActionStop3: Color,
/** Background gradient stop for super and send buttons */
val gradientActionStop4: Color,
/** Subtle background gradient stop for critical */
val gradientCriticalStop1: Color,
/** Subtle background gradient stop for critical */
val gradientCriticalStop2: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop1: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop2: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop3: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop4: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop5: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop6: Color,
/** Subtle background gradient stop for message highlight and bloom */
val gradientSubtleStop1: Color,
/** Subtle background gradient stop for message highlight and bloom */

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
@ -37,9 +37,12 @@ val compoundColorsDark = SemanticColors(
bgActionTertiaryHovered = DarkColorTokens.colorGray300,
bgActionTertiaryRest = DarkColorTokens.colorThemeBg,
bgActionTertiarySelected = DarkColorTokens.colorGray400,
bgBadgeAccent = DarkColorTokens.colorAlphaGreen500,
bgBadgeDefault = DarkColorTokens.colorAlphaGray500,
bgBadgeInfo = DarkColorTokens.colorAlphaBlue500,
bgBadgeAccent = DarkColorTokens.colorGreen400,
bgBadgeCritical = DarkColorTokens.colorRed300,
bgBadgeDefault = DarkColorTokens.colorThemeBg,
bgBadgeInfo = DarkColorTokens.colorBlue400,
bgBadgePrimary = DarkColorTokens.colorGray1400,
bgBadgeSecondary = DarkColorTokens.colorGray400,
bgCanvasDefault = DarkColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = DarkColorTokens.colorGray300,
bgCanvasDisabled = DarkColorTokens.colorGray200,
@ -73,12 +76,10 @@ val compoundColorsDark = SemanticColors(
gradientActionStop2 = DarkColorTokens.colorGreen900,
gradientActionStop3 = DarkColorTokens.colorGreen700,
gradientActionStop4 = DarkColorTokens.colorGreen500,
gradientInfoStop1 = DarkColorTokens.colorAlphaBlue500,
gradientInfoStop2 = DarkColorTokens.colorAlphaBlue400,
gradientInfoStop3 = DarkColorTokens.colorAlphaBlue300,
gradientInfoStop4 = DarkColorTokens.colorAlphaBlue200,
gradientInfoStop5 = DarkColorTokens.colorAlphaBlue100,
gradientInfoStop6 = DarkColorTokens.colorTransparent,
gradientCriticalStop1 = DarkColorTokens.colorRed200,
gradientCriticalStop2 = DarkColorTokens.colorThemeBg,
gradientInfoStop1 = DarkColorTokens.colorBlue200,
gradientInfoStop2 = DarkColorTokens.colorThemeBg,
gradientSubtleStop1 = DarkColorTokens.colorAlphaGreen500,
gradientSubtleStop2 = DarkColorTokens.colorAlphaGreen400,
gradientSubtleStop3 = DarkColorTokens.colorAlphaGreen300,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
@ -37,9 +37,12 @@ val compoundColorsHcDark = SemanticColors(
bgActionTertiaryHovered = DarkHcColorTokens.colorGray300,
bgActionTertiaryRest = DarkHcColorTokens.colorThemeBg,
bgActionTertiarySelected = DarkHcColorTokens.colorGray400,
bgBadgeAccent = DarkHcColorTokens.colorAlphaGreen500,
bgBadgeDefault = DarkHcColorTokens.colorAlphaGray500,
bgBadgeInfo = DarkHcColorTokens.colorAlphaBlue500,
bgBadgeAccent = DarkHcColorTokens.colorGreen400,
bgBadgeCritical = DarkHcColorTokens.colorRed300,
bgBadgeDefault = DarkHcColorTokens.colorThemeBg,
bgBadgeInfo = DarkHcColorTokens.colorBlue400,
bgBadgePrimary = DarkHcColorTokens.colorGray1400,
bgBadgeSecondary = DarkHcColorTokens.colorGray400,
bgCanvasDefault = DarkHcColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = DarkHcColorTokens.colorGray300,
bgCanvasDisabled = DarkHcColorTokens.colorGray200,
@ -73,12 +76,10 @@ val compoundColorsHcDark = SemanticColors(
gradientActionStop2 = DarkHcColorTokens.colorGreen900,
gradientActionStop3 = DarkHcColorTokens.colorGreen700,
gradientActionStop4 = DarkHcColorTokens.colorGreen500,
gradientInfoStop1 = DarkHcColorTokens.colorAlphaBlue500,
gradientInfoStop2 = DarkHcColorTokens.colorAlphaBlue400,
gradientInfoStop3 = DarkHcColorTokens.colorAlphaBlue300,
gradientInfoStop4 = DarkHcColorTokens.colorAlphaBlue200,
gradientInfoStop5 = DarkHcColorTokens.colorAlphaBlue100,
gradientInfoStop6 = DarkHcColorTokens.colorTransparent,
gradientCriticalStop1 = DarkHcColorTokens.colorRed200,
gradientCriticalStop2 = DarkHcColorTokens.colorThemeBg,
gradientInfoStop1 = DarkHcColorTokens.colorBlue200,
gradientInfoStop2 = DarkHcColorTokens.colorThemeBg,
gradientSubtleStop1 = DarkHcColorTokens.colorAlphaGreen500,
gradientSubtleStop2 = DarkHcColorTokens.colorAlphaGreen400,
gradientSubtleStop3 = DarkHcColorTokens.colorAlphaGreen300,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
@ -37,9 +37,12 @@ val compoundColorsLight = SemanticColors(
bgActionTertiaryHovered = LightColorTokens.colorGray300,
bgActionTertiaryRest = LightColorTokens.colorThemeBg,
bgActionTertiarySelected = LightColorTokens.colorGray400,
bgBadgeAccent = LightColorTokens.colorAlphaGreen400,
bgBadgeDefault = LightColorTokens.colorAlphaGray400,
bgBadgeInfo = LightColorTokens.colorAlphaBlue400,
bgBadgeAccent = LightColorTokens.colorGreen400,
bgBadgeCritical = LightColorTokens.colorRed300,
bgBadgeDefault = LightColorTokens.colorThemeBg,
bgBadgeInfo = LightColorTokens.colorBlue400,
bgBadgePrimary = LightColorTokens.colorGray1400,
bgBadgeSecondary = LightColorTokens.colorGray400,
bgCanvasDefault = LightColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = LightColorTokens.colorThemeBg,
bgCanvasDisabled = LightColorTokens.colorGray200,
@ -73,12 +76,10 @@ val compoundColorsLight = SemanticColors(
gradientActionStop2 = LightColorTokens.colorGreen700,
gradientActionStop3 = LightColorTokens.colorGreen900,
gradientActionStop4 = LightColorTokens.colorGreen1100,
gradientInfoStop1 = LightColorTokens.colorAlphaBlue500,
gradientInfoStop2 = LightColorTokens.colorAlphaBlue400,
gradientInfoStop3 = LightColorTokens.colorAlphaBlue300,
gradientInfoStop4 = LightColorTokens.colorAlphaBlue200,
gradientInfoStop5 = LightColorTokens.colorAlphaBlue100,
gradientInfoStop6 = LightColorTokens.colorTransparent,
gradientCriticalStop1 = LightColorTokens.colorRed200,
gradientCriticalStop2 = LightColorTokens.colorThemeBg,
gradientInfoStop1 = LightColorTokens.colorBlue200,
gradientInfoStop2 = LightColorTokens.colorThemeBg,
gradientSubtleStop1 = LightColorTokens.colorAlphaGreen500,
gradientSubtleStop2 = LightColorTokens.colorAlphaGreen400,
gradientSubtleStop3 = LightColorTokens.colorAlphaGreen300,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
@ -37,9 +37,12 @@ val compoundColorsHcLight = SemanticColors(
bgActionTertiaryHovered = LightHcColorTokens.colorGray300,
bgActionTertiaryRest = LightHcColorTokens.colorThemeBg,
bgActionTertiarySelected = LightHcColorTokens.colorGray400,
bgBadgeAccent = LightHcColorTokens.colorAlphaGreen400,
bgBadgeDefault = LightHcColorTokens.colorAlphaGray400,
bgBadgeInfo = LightHcColorTokens.colorAlphaBlue400,
bgBadgeAccent = LightHcColorTokens.colorGreen400,
bgBadgeCritical = LightHcColorTokens.colorRed300,
bgBadgeDefault = LightHcColorTokens.colorThemeBg,
bgBadgeInfo = LightHcColorTokens.colorBlue400,
bgBadgePrimary = LightHcColorTokens.colorGray1400,
bgBadgeSecondary = LightHcColorTokens.colorGray400,
bgCanvasDefault = LightHcColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = LightHcColorTokens.colorThemeBg,
bgCanvasDisabled = LightHcColorTokens.colorGray200,
@ -73,12 +76,10 @@ val compoundColorsHcLight = SemanticColors(
gradientActionStop2 = LightHcColorTokens.colorGreen700,
gradientActionStop3 = LightHcColorTokens.colorGreen900,
gradientActionStop4 = LightHcColorTokens.colorGreen1100,
gradientInfoStop1 = LightHcColorTokens.colorAlphaBlue500,
gradientInfoStop2 = LightHcColorTokens.colorAlphaBlue400,
gradientInfoStop3 = LightHcColorTokens.colorAlphaBlue300,
gradientInfoStop4 = LightHcColorTokens.colorAlphaBlue200,
gradientInfoStop5 = LightHcColorTokens.colorAlphaBlue100,
gradientInfoStop6 = LightHcColorTokens.colorTransparent,
gradientCriticalStop1 = LightHcColorTokens.colorRed200,
gradientCriticalStop2 = LightHcColorTokens.colorThemeBg,
gradientInfoStop1 = LightHcColorTokens.colorBlue200,
gradientInfoStop2 = LightHcColorTokens.colorThemeBg,
gradientSubtleStop1 = LightHcColorTokens.colorAlphaGreen500,
gradientSubtleStop2 = LightHcColorTokens.colorAlphaGreen400,
gradientSubtleStop3 = LightHcColorTokens.colorAlphaGreen300,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2m0,18c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
<path
android:pathData="M13.49,11.38c0.43,-1.22 0.17,-2.64 -0.81,-3.62a3.47,3.47 0,0 0,-4.1 -0.59l2.35,2.35 -1.41,1.41 -2.35,-2.35c-0.71,1.32 -0.52,2.99 0.59,4.1 0.98,0.98 2.4,1.24 3.62,0.81l3.41,3.41c0.2,0.2 0.51,0.2 0.71,0l1.4,-1.4c0.2,-0.2 0.2,-0.51 0,-0.71z"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.5,2 L11,6.5 15.5,8 11,9.5 9.5,14 8,9.5 3.5,8 8,6.5zM19,11 L20,14 23,15 20,16 19,19 18,16 15,15 18,14zM12,17 L12.75,19.25L15,20l-2.25,0.75L12,23l-0.75,-2.25L9,20l2.25,-0.75z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -4,11 +4,11 @@
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24H0z"/>
<path
android:pathData="M21.167,3.75L7.417,3.75c-0.633,0 -1.128,0.32 -1.458,0.807L1,12l4.96,7.434c0.33,0.486 0.824,0.816 1.457,0.816h13.75A1.84,1.84 0,0 0,23 18.417L23,5.583a1.84,1.84 0,0 0,-1.833 -1.833m0,14.667L7.48,18.417L3.2,12l4.272,-6.417h13.695zM10.542,16.583 L13.833,13.293 17.124,16.583 18.417,15.291L15.126,12l3.29,-3.29 -1.292,-1.293 -3.29,3.29 -3.291,-3.29L9.25,8.709 12.54,12l-3.29,3.29z"
android:fillColor="#FF000000"/>
</group>
<path
android:pathData="M15.043,8.457a1,1 0,0 1,1.414 1.414l-2.043,2.043 2.129,2.129a1,1 0,1 1,-1.414 1.414l-2.13,-2.129 -2.127,2.129a1,1 0,0 1,-1.415 -1.414l2.129,-2.129 -2.043,-2.043a1,1 0,0 1,1.414 -1.414L13,10.5z"
android:fillColor="#FF000000"/>
<path
android:pathData="M20,4a2,2 0,0 1,2 2v12a2,2 0,0 1,-2 2H7.28a2,2 0,0 1,-1.655 -0.877l-4.072,-6a2,2 0,0 1,0 -2.246l4.072,-6A2,2 0,0 1,7.28 4zM3.208,12l4.072,6H20V6H7.28z"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -5,6 +5,7 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M21.15,4H7.283c-0.638,0 -1.137,0.311 -1.47,0.782l-4.66,6.73a0.87,0.87 0,0 0,0 0.986l4.66,6.72c0.333,0.462 0.832,0.782 1.47,0.782h13.869C22.168,20 23,19.2 23,18.222V5.778C23,4.8 22.168,4 21.15,4m-3.42,11.822a0.947,0.947 0,0 1,-1.304 0l-2.672,-2.569 -2.672,2.57a0.947,0.947 0,0 1,-1.303 0,0.86 0.86,0 0,1 0,-1.254L12.45,12 9.779,9.431a0.86,0.86 0,0 1,0 -1.253,0.947 0.947,0 0,1 1.303,0l2.672,2.569 2.672,-2.57a0.947,0.947 0,0 1,1.304 0c0.36,0.347 0.36,0.907 0,1.254L15.058,12l2.672,2.569a0.877,0.877 0,0 1,0 1.253"
android:fillColor="#FF000000"/>
android:pathData="M20,4a2,2 0,0 1,2 2v12a2,2 0,0 1,-2 2L7.33,20a2,2 0,0 1,-1.673 -0.902l-3.937,-6a2,2 0,0 1,0 -2.196l3.937,-6A2,2 0,0 1,7.33 4zM16.457,8.457a1,1 0,0 0,-1.414 0L13,10.5l-2.043,-2.043a1,1 0,0 0,-1.414 1.414l2.043,2.043 -2.129,2.129a1,1 0,0 0,1.414 1.414l2.13,-2.129 2.128,2.129a1,1 0,0 0,1.414 -1.414l-2.129,-2.129 2.043,-2.043a1,1 0,0 0,0 -1.414"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,8h-1.81a6,6 0,0 0,-1.82 -1.96l0.93,-0.93a0.996,0.996 0,1 0,-1.41 -1.41l-1.47,1.47C12.96,5.06 12.49,5 12,5s-0.96,0.06 -1.41,0.17L9.11,3.7A0.996,0.996 0,1 0,7.7 5.11l0.92,0.93C7.88,6.55 7.26,7.22 6.81,8H5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1.09c-0.05,0.33 -0.09,0.66 -0.09,1v1H5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1v1c0,0.34 0.04,0.67 0.09,1H5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-1.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h1c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-1v-1c0,-0.34 -0.04,-0.67 -0.09,-1H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1m-6,8h-2c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h2c0.55,0 1,0.45 1,1s-0.45,1 -1,1m0,-4h-2c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h2c0.55,0 1,0.45 1,1s-0.45,1 -1,1"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19.5,9.385q1.458,0 2.48,1.021Q23,11.426 23,12.885q0,1.122 -0.642,2.027a3.4,3.4 0,0 1,-1.691 1.269v0.204l0.758,0.759q0.088,0.087 0.124,0.189a0.7,0.7 0,0 1,0.036 0.219q0,0.116 -0.036,0.218a0.5,0.5 0,0 1,-0.124 0.19l-0.758,0.759 0.802,1.02a0.6,0.6 0,0 1,0.11 0.197,0.5 0.5,0 0,1 0.02,0.226 0.57,0.57 0,0 1,-0.204 0.38l-1.501,1.313a0.62,0.62 0,0 1,-0.409 0.145,0.5 0.5,0 0,1 -0.212,-0.044 0.6,0.6 0,0 1,-0.167 -0.116l-0.598,-0.599a0.58,0.58 0,0 1,-0.175 -0.423v-4.637a3.54,3.54 0,0 1,-1.875 -1.553A3.36,3.36 0,0 1,16 12.885q0,-1.458 1.02,-2.479 1.022,-1.02 2.48,-1.021m0,2.334q-0.481,0 -0.824,0.343a1.12,1.12 0,0 0,-0.343 0.823q0,0.48 0.343,0.824 0.342,0.343 0.824,0.343t0.824,-0.343q0.343,-0.342 0.343,-0.824 0,-0.48 -0.343,-0.823a1.12,1.12 0,0 0,-0.824 -0.343"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
<path
android:pathData="M11,13.125q1.532,0 3.025,0.298a5.25,5.25 0,0 0,1.054 2.737q0.542,0.714 1.254,1.217v3.441q0,0.092 0.007,0.182H3.125q-0.478,0 -0.802,-0.323A1.1,1.1 0,0 1,2 19.875V17.85q0,-0.957 0.492,-1.758A3.3,3.3 0,0 1,3.8 14.869a16.7,16.7 0,0 1,3.544 -1.309A15.5,15.5 0,0 1,11 13.125M11,3q1.857,0 3.178,1.322Q15.5,5.644 15.5,7.5t-1.322,3.178T11,12t-3.178,-1.322T6.5,7.5t1.322,-3.178T11,3"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,20q-0.824,0 -1.412,-0.587A1.93,1.93 0,0 1,2 18L2,6q0,-0.824 0.587,-1.412A1.93,1.93 0,0 1,4 4h6l2,2h8q0.824,0 1.413,0.588Q22,7.175 22,8v10q0,0.824 -0.587,1.413A1.93,1.93 0,0 1,20 20zM4,18h16L20,8h-8.825l-2,-2L4,6z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -4,7 +4,7 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16.099,2.4a4.1,4.1 0,0 1,-1.057 3.074c-0.747,0.862 -1.878,1.358 -3.07,1.347 -0.075,-1.081 0.315,-2.146 1.085,-2.96 0.78,-0.825 1.866,-1.346 3.042,-1.461m3.767,6.54c-1.37,0.783 -2.213,2.163 -2.234,3.657 0.002,1.69 1.092,3.215 2.768,3.873a9.4,9.4 0,0 1,-1.44 2.723c-0.848,1.178 -1.737,2.329 -3.149,2.35 -0.67,0.015 -1.124,-0.165 -1.596,-0.351 -0.493,-0.195 -1.006,-0.398 -1.809,-0.398 -0.851,0 -1.388,0.21 -1.905,0.412 -0.447,0.174 -0.88,0.343 -1.49,0.367 -1.343,0.046 -2.37,-1.258 -3.25,-2.425 -1.756,-2.383 -3.124,-6.716 -1.29,-9.664 0.861,-1.437 2.471,-2.349 4.241,-2.402 0.763,-0.015 1.494,0.258 2.136,0.497 0.49,0.183 0.928,0.347 1.286,0.347 0.315,0 0.74,-0.157 1.237,-0.34 0.78,-0.288 1.737,-0.64 2.71,-0.545 1.514,0.044 2.917,0.748 3.785,1.9"
android:pathData="M16.099,2.4a4.1,4.1 0,0 1,-1.057 3.073c-0.747,0.863 -1.878,1.36 -3.07,1.348 -0.075,-1.081 0.315,-2.146 1.085,-2.96 0.78,-0.825 1.866,-1.346 3.042,-1.461m3.767,6.54c-1.37,0.783 -2.214,2.163 -2.234,3.657 0.002,1.69 1.092,3.215 2.768,3.873a9.4,9.4 0,0 1,-1.44 2.723c-0.848,1.178 -1.737,2.329 -3.149,2.35 -0.671,0.015 -1.124,-0.165 -1.596,-0.351 -0.493,-0.195 -1.006,-0.398 -1.809,-0.398 -0.852,0 -1.388,0.21 -1.905,0.412 -0.447,0.174 -0.88,0.343 -1.49,0.367 -1.343,0.046 -2.37,-1.258 -3.25,-2.425 -1.756,-2.383 -3.124,-6.716 -1.29,-9.664 0.86,-1.437 2.471,-2.349 4.241,-2.402 0.763,-0.015 1.494,0.258 2.135,0.497 0.49,0.183 0.929,0.347 1.287,0.347 0.315,0 0.74,-0.157 1.237,-0.34 0.78,-0.288 1.737,-0.64 2.71,-0.545 1.514,0.044 2.917,0.748 3.785,1.9"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9,4c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m6,0c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,-6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,-6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6.56,7.98C6.1,7.52 5.31,7.6 5,8.17c-0.28,0.51 -0.5,1.03 -0.67,1.58 -0.19,0.63 0.31,1.25 0.96,1.25h0.01c0.43,0 0.82,-0.28 0.94,-0.7q0.18,-0.6 0.48,-1.17c0.22,-0.37 0.15,-0.84 -0.16,-1.15M5.31,13h-0.02c-0.65,0 -1.15,0.62 -0.96,1.25 0.16,0.54 0.38,1.07 0.66,1.58 0.31,0.57 1.11,0.66 1.57,0.2 0.3,-0.31 0.38,-0.77 0.17,-1.15 -0.2,-0.37 -0.36,-0.76 -0.48,-1.16a0.97,0.97 0,0 0,-0.94 -0.72m2.85,6.02q0.765,0.42 1.59,0.66c0.62,0.18 1.24,-0.32 1.24,-0.96v-0.03c0,-0.43 -0.28,-0.82 -0.7,-0.94 -0.4,-0.12 -0.78,-0.28 -1.15,-0.48a0.97,0.97 0,0 0,-1.16 0.17l-0.03,0.03c-0.45,0.45 -0.36,1.24 0.21,1.55M13,4.07v-0.66c0,-0.89 -1.08,-1.34 -1.71,-0.71L9.17,4.83c-0.4,0.4 -0.4,1.04 0,1.43l2.13,2.08c0.63,0.62 1.7,0.17 1.7,-0.72V6.09c2.84,0.48 5,2.94 5,5.91 0,2.73 -1.82,5.02 -4.32,5.75a0.97,0.97 0,0 0,-0.68 0.94v0.02c0,0.65 0.61,1.14 1.23,0.96A7.976,7.976 0,0 0,20 12c0,-4.08 -3.05,-7.44 -7,-7.93"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24H0z"/>
<path
android:pathData="M14.83,4.83 L12.7,2.7c-0.62,-0.62 -1.7,-0.18 -1.7,0.71v0.66C7.06,4.56 4,7.92 4,12c0,3.64 2.43,6.71 5.77,7.68 0.62,0.18 1.23,-0.32 1.23,-0.96v-0.03a0.97,0.97 0,0 0,-0.68 -0.94A5.98,5.98 0,0 1,6 12c0,-2.97 2.16,-5.43 5,-5.91v1.53c0,0.89 1.07,1.33 1.7,0.71l2.13,-2.08a0.99,0.99 0,0 0,0 -1.42m4.84,4.93q-0.24,-0.825 -0.66,-1.59c-0.31,-0.57 -1.1,-0.66 -1.56,-0.2l-0.01,0.01c-0.31,0.31 -0.38,0.78 -0.17,1.16 0.2,0.37 0.36,0.76 0.48,1.16 0.12,0.42 0.51,0.7 0.94,0.7h0.02c0.65,0 1.15,-0.62 0.96,-1.24M13,18.68v0.02c0,0.65 0.62,1.14 1.24,0.96q0.825,-0.24 1.59,-0.66c0.57,-0.31 0.66,-1.1 0.2,-1.56l-0.02,-0.02a0.97,0.97 0,0 0,-1.16 -0.17c-0.37,0.21 -0.76,0.37 -1.16,0.49 -0.41,0.12 -0.69,0.51 -0.69,0.94m4.44,-2.65c0.46,0.46 1.25,0.37 1.56,-0.2 0.28,-0.51 0.5,-1.04 0.67,-1.59 0.18,-0.62 -0.31,-1.24 -0.96,-1.24h-0.02c-0.44,0 -0.82,0.28 -0.94,0.7q-0.18,0.6 -0.48,1.17c-0.21,0.38 -0.13,0.86 0.17,1.16"
android:fillColor="#FF000000"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16,7a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,15 8q0,0.424 0.287,0.713Q15.576,9 16,9t0.712,-0.287A0.97,0.97 0,0 0,17 8a0.97,0.97 0,0 0,-0.288 -0.713A0.97,0.97 0,0 0,16 7m0,4a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,15 12q0,0.424 0.287,0.713 0.288,0.287 0.713,0.287 0.424,0 0.712,-0.287A0.97,0.97 0,0 0,17 12a0.97,0.97 0,0 0,-0.288 -0.713A0.97,0.97 0,0 0,16 11m0,4a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,15 16q0,0.424 0.287,0.712 0.288,0.288 0.713,0.288 0.424,0 0.712,-0.288A0.97,0.97 0,0 0,17 16a0.97,0.97 0,0 0,-0.288 -0.713A0.97,0.97 0,0 0,16 15m-4,-8L8,7a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,7 8q0,0.424 0.287,0.713Q7.576,9 8,9h4q0.424,0 0.713,-0.287A0.97,0.97 0,0 0,13 8a0.97,0.97 0,0 0,-0.287 -0.713A0.97,0.97 0,0 0,12 7m0,4L8,11a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,7 12q0,0.424 0.287,0.713Q7.576,13 8,13h4q0.424,0 0.713,-0.287A0.97,0.97 0,0 0,13 12a0.97,0.97 0,0 0,-0.287 -0.713A0.97,0.97 0,0 0,12 11m0,4L8,15a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,7 16q0,0.424 0.287,0.712Q7.576,17 8,17h4q0.424,0 0.713,-0.288A0.97,0.97 0,0 0,13 16a0.97,0.97 0,0 0,-0.287 -0.713A0.97,0.97 0,0 0,12 15m7,-12q0.824,0 1.413,0.587Q21,4.176 21,5v14q0,0.824 -0.587,1.413A1.93,1.93 0,0 1,19 21L5,21q-0.824,0 -1.412,-0.587A1.93,1.93 0,0 1,3 19L3,5q0,-0.824 0.587,-1.412A1.93,1.93 0,0 1,5 3zM19,5L5,5v14h14z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16,18v2L8,20v-2zM18,16L18,8a2,2 0,0 0,-2 -2L8,6a2,2 0,0 0,-2 2v8a2,2 0,0 0,2 2v2a4,4 0,0 1,-4 -4L4,8a4,4 0,0 1,4 -4h8a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4v-2a2,2 0,0 0,2 -2"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,8a4,4 0,0 1,4 -4h8a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4H8a4,4 0,0 1,-4 -4z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,22c5.52,0 10,-4.48 10,-10S17.52,2 12,2 2,6.48 2,12s4.48,10 10,10m1,-17.93c3.94,0.49 7,3.85 7,7.93s-3.05,7.44 -7,7.93z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13,2a2,2 0,0 1,2 2v4h6a2,2 0,0 1,2 2v12.586c0,0.89 -1.077,1.337 -1.707,0.707L19,21h-8a2,2 0,0 1,-2 -2v-4L5,15l-2.293,2.293c-0.63,0.63 -1.707,0.184 -1.707,-0.707L1,4a2,2 0,0 1,2 -2zM15.5,12.125L12,12.125v1.25h4.37c-0.031,0.73 -0.325,1.457 -0.871,2.151a4.4,4.4 0,0 1,-0.613 -1.026h-1.33c0.202,0.69 0.57,1.335 1.067,1.932 -0.524,0.448 -1.162,0.873 -1.912,1.263l0.578,1.11a11.3,11.3 0,0 0,2.21 -1.483c0.633,0.553 1.382,1.05 2.212,1.483l0.578,-1.11c-0.75,-0.39 -1.388,-0.815 -1.912,-1.263 0.758,-0.912 1.213,-1.939 1.245,-3.057L20,13.375v-1.25h-3.25L16.75,10.25L15.5,10.25zM3,14.172l0.586,-0.586A2,2 0,0 1,5 13h4v-2.47L6.96,10.53L6.563,12L5,12l2.031,-7L8.97,5l0.96,3.312A2,2 0,0 1,11 8h2L13,4L3,4zM7.306,9.245h1.386l-0.67,-2.481h-0.047z"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.01,5v1H11c0.333,0 1,0 1.5,0.5S13,7.667 13,8v7.01c0,0.54 0.45,0.99 0.99,0.99H15v-1a2,2 0,0 1,2 -2h3a2,2 0,0 1,2 2v4a2,2 0,0 1,-2 2h-3a2,2 0,0 1,-2 -2v-1h-1.01C12.34,18 11,16.66 11,15.01V9c0,-1 0,-1 -1,-1H9v1a2,2 0,0 1,-2 2H4a2,2 0,0 1,-2 -2V5c0,-1.1 0.9,-2 2,-2h3.01a2,2 0,0 1,2 2"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16,4a2,2 0,0 1,2 2v4.286l3.35,-2.871a1,1 0,0 1,1.65 0.759v7.652a1,1 0,0 1,-1.65 0.759L18,13.714V18a2,2 0,0 1,-2 2H6a4,4 0,0 1,-4 -4V8a4,4 0,0 1,4 -4zM9.55,9l-0.103,0.005a1,1 0,0 0,0 1.99L9.55,11h0.571l-2.828,2.828a1,1 0,0 0,1.414 1.414L11.55,12.4v0.6l0.005,0.102a1,1 0,0 0,1.99 0L13.55,13v-3l-0.005,-0.103A1,1 0,0 0,12.55 9z"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -4,12 +4,8 @@
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24H0z"/>
<path
android:pathData="M8.929,15.1a13.6,13.6 0,0 0,4.654 3.066q2.62,1.036 5.492,0.923h0.008l0.003,-0.004 0.003,-0.002 -0.034,-3.124 -3.52,-0.483 -1.791,1.792 -0.645,-0.322a13.5,13.5 0,0 1,-3.496 -2.52,13.4 13.4,0 0,1 -2.52,-3.496l-0.322,-0.644 1.792,-1.792 -0.483,-3.519 -3.123,-0.034 -0.003,0.002 -0.003,0.004v0.002a13.65,13.65 0,0 0,0.932 5.492A13.4,13.4 0,0 0,8.93 15.1m3.92,4.926a15.6,15.6 0,0 1,-5.334 -3.511,15.4 15.4,0 0,1 -3.505,-5.346 15.6,15.6 0,0 1,-1.069 -6.274,1.93 1.93,0 0,1 0.589,-1.366c0.366,-0.366 0.84,-0.589 1.386,-0.589h0.01l3.163,0.035a1.96,1.96 0,0 1,1.958 1.694v0.005l0.487,3.545v0.003c0.043,0.297 0.025,0.605 -0.076,0.907a2,2 0,0 1,-0.485 0.773l-0.762,0.762a11.4,11.4 0,0 0,3.206 3.54q0.457,0.33 0.948,0.614l0.762,-0.761a2,2 0,0 1,0.774 -0.486c0.302,-0.1 0.61,-0.118 0.907,-0.076l3.553,0.487a1.96,1.96 0,0 1,1.694 1.958l0.034,3.174c0,0.546 -0.223,1.02 -0.588,1.386 -0.361,0.36 -0.827,0.582 -1.363,0.588a15.3,15.3 0,0 1,-6.29 -1.062"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</group>
<path
android:pathData="M8.929,15.1a13.6,13.6 0,0 0,4.654 3.066q2.62,1.036 5.492,0.923h0.008l0.003,-0.004 0.003,-0.002 -0.034,-3.124 -3.52,-0.483 -1.791,1.792 -0.645,-0.322a13.5,13.5 0,0 1,-3.496 -2.52,13.4 13.4,0 0,1 -2.52,-3.496l-0.322,-0.645 1.792,-1.791 -0.483,-3.52 -3.123,-0.033 -0.003,0.002 -0.003,0.004v0.002a13.65,13.65 0,0 0,0.932 5.492A13.4,13.4 0,0 0,8.93 15.1m3.92,4.926a15.6,15.6 0,0 1,-5.334 -3.511,15.4 15.4,0 0,1 -3.505,-5.346 15.6,15.6 0,0 1,-1.069 -6.274,1.93 1.93,0 0,1 0.589,-1.366c0.366,-0.366 0.84,-0.589 1.386,-0.589h0.01l3.163,0.035a1.96,1.96 0,0 1,1.958 1.694v0.005l0.487,3.545v0.003c0.043,0.297 0.025,0.605 -0.076,0.907a2,2 0,0 1,-0.485 0.773l-0.762,0.762a11.3,11.3 0,0 0,1.806 2.348,11.4 11.4,0 0,0 2.348,1.806l0.762,-0.762a2,2 0,0 1,0.774 -0.485c0.302,-0.1 0.61,-0.118 0.907,-0.076l3.553,0.487a1.96,1.96 0,0 1,1.694 1.958l0.034,3.174c0,0.546 -0.223,1.02 -0.588,1.386 -0.36,0.36 -0.827,0.582 -1.363,0.588a15.3,15.3 0,0 1,-6.29 -1.062"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7.623,3.04a1.07,1.07 0,0 1,1.086 0.929l0.542,3.954q0.039,0.27 -0.038,0.504a1.1,1.1 0,0 1,-0.272 0.427l-1.64,1.64Q7.806,11.5 8.456,12.4c0.433,0.601 1.444,1.697 1.444,1.697 0.013,0.012 1.098,1.014 1.696,1.444q0.9,0.65 1.909,1.153l1.64,-1.64q0.194,-0.194 0.426,-0.27a1.1,1.1 0,0 1,0.504 -0.04l3.953,0.543q0.407,0.058 0.67,0.358 0.26,0.301 0.26,0.728l0.04,3.527q0,0.427 -0.33,0.756 -0.33,0.33 -0.756,0.33a16,16 0,0 1,-6.57 -1.105,16.2 16.2,0 0,1 -5.563,-3.663 16.1,16.1 0,0 1,-3.653 -5.573,16.3 16.3,0 0,1 -1.116,-6.56q0,-0.426 0.329,-0.756Q3.67,3 4.095,3zM20.25,3q0.405,0 0.707,0.3 0.3,0.301 0.3,0.708t-0.3,0.707l-1.414,1.414 1.414,1.414q0.3,0.3 0.3,0.707t-0.3,0.707 -0.707,0.3 -0.707,-0.3l-1.414,-1.414 -1.414,1.414q-0.3,0.3 -0.707,0.3t-0.707,-0.3T15,8.25q0,-0.406 0.3,-0.707l1.415,-1.414L15.3,4.715q-0.3,-0.3 -0.301,-0.707 0,-0.407 0.3,-0.707t0.71,-0.301q0.405,0 0.707,0.3l1.414,1.415L19.543,3.3q0.3,-0.3 0.707,-0.301"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7.623,3.04a1.07,1.07 0,0 1,1.086 0.929l0.542,3.954q0.039,0.27 -0.038,0.504a1.1,1.1 0,0 1,-0.272 0.427l-1.64,1.64Q7.806,11.5 8.456,12.4c0.433,0.601 1.444,1.697 1.444,1.697 0.013,0.012 1.098,1.014 1.696,1.444q0.9,0.65 1.909,1.153l1.64,-1.64q0.194,-0.194 0.426,-0.27a1.1,1.1 0,0 1,0.504 -0.04l3.953,0.543q0.407,0.058 0.67,0.358 0.26,0.301 0.26,0.728l0.04,3.527q0,0.427 -0.33,0.756 -0.33,0.33 -0.756,0.33a16,16 0,0 1,-6.57 -1.105,16.2 16.2,0 0,1 -5.563,-3.663 16.1,16.1 0,0 1,-3.653 -5.573,16.3 16.3,0 0,1 -1.116,-6.56q0,-0.426 0.329,-0.756Q3.67,3 4.095,3z"
android:fillColor="#FF000000"/>
<path
android:pathData="M16,5q0.425,0 0.713,0.287Q17,5.575 17,6a0.97,0.97 0,0 1,-0.287 0.713A0.97,0.97 0,0 1,16 7h-0.5l2.2,2.15 2.4,-2.4a0.95,0.95 0,0 1,0.7 -0.275,0.95 0.95,0 0,1 0.7,0.275q0.3,0.3 0.3,0.7a0.92,0.92 0,0 1,-0.275 0.675l-3.125,3.15a0.8,0.8 0,0 1,-0.312 0.225,1.04 1.04,0 0,1 -0.776,0 0.9,0.9 0,0 1,-0.312 -0.2l-3,-3V9a0.97,0.97 0,0 1,-0.287 0.713A0.97,0.97 0,0 1,13 10a0.97,0.97 0,0 1,-0.713 -0.287A0.97,0.97 0,0 1,12 9V6q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,13 5z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7.623,3.04a1.07,1.07 0,0 1,1.086 0.929l0.542,3.954q0.039,0.27 -0.038,0.504a1.1,1.1 0,0 1,-0.272 0.427l-1.64,1.64Q7.806,11.5 8.456,12.4c0.433,0.601 1.444,1.697 1.444,1.697 0.013,0.012 1.098,1.014 1.696,1.444q0.9,0.65 1.909,1.153l1.64,-1.64q0.194,-0.194 0.426,-0.27a1.1,1.1 0,0 1,0.504 -0.04l3.953,0.543q0.407,0.058 0.67,0.358 0.26,0.301 0.26,0.728l0.04,3.527q0,0.427 -0.33,0.756 -0.33,0.33 -0.756,0.33a16,16 0,0 1,-6.57 -1.105,16.2 16.2,0 0,1 -5.563,-3.663 16.1,16.1 0,0 1,-3.653 -5.573,16.3 16.3,0 0,1 -1.116,-6.56q0,-0.426 0.329,-0.756Q3.67,3 4.095,3z"
android:fillColor="#FF000000"/>
<path
android:pathData="M19.964,3a1,1 0,0 1,0.995 0.897l0.005,0.103v3l-0.005,0.103a1,1 0,0 1,-1.99 0L18.964,7v-0.605l-4.05,4.02A1,1 0,0 1,13.5 9l4.03,-4h-0.566l-0.103,-0.005a1,1 0,0 1,0 -1.99L16.964,3z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24H0z"/>
<path
android:pathData="M10.5,6.5q0.425,0 0.713,0.287 0.288,0.288 0.287,0.713v2h2q0.425,0 0.713,0.287 0.288,0.288 0.287,0.713a0.97,0.97 0,0 1,-0.287 0.713,0.97 0.97,0 0,1 -0.713,0.287h-2v2a0.97,0.97 0,0 1,-0.287 0.713,0.97 0.97,0 0,1 -0.713,0.287 0.97,0.97 0,0 1,-0.713 -0.287,0.97 0.97,0 0,1 -0.287,-0.713v-2h-2a0.97,0.97 0,0 1,-0.713 -0.287,0.97 0.97,0 0,1 -0.287,-0.713q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,7.5 9.5h2v-2q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,10.5 6.5"
android:fillColor="#FF000000"/>
<path
android:pathData="M10.5,3a7.5,7.5 0,0 1,5.963 12.049l3.244,3.244a1,1 0,1 1,-1.414 1.414l-3.244,-3.244A7.5,7.5 0,1 1,10.5 3m0,2a5.5,5.5 0,1 0,0 11,5.5 5.5,0 0,0 0,-11"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
<path
android:pathData="M15.05,16.463a7.5,7.5 0,1 1,1.414 -1.414l3.243,3.244a1,1 0,0 1,-1.414 1.414zM16,10.5a5.5,5.5 0,1 0,-11 0,5.5 5.5,0 0,0 11,0"
android:fillColor="#FF000000"/>
<path
android:pathData="M7.875,11.375h1.75v1.75q0,0.372 0.252,0.623A0.85,0.85 0,0 0,10.5 14a0.85,0.85 0,0 0,0.623 -0.252,0.85 0.85,0 0,0 0.252,-0.623v-1.75h1.75a0.85,0.85 0,0 0,0.623 -0.252A0.85,0.85 0,0 0,14 10.5a0.85,0.85 0,0 0,-0.252 -0.623,0.85 0.85,0 0,0 -0.623,-0.252h-1.75v-1.75a0.85,0.85 0,0 0,-0.252 -0.623A0.85,0.85 0,0 0,10.5 7a0.85,0.85 0,0 0,-0.623 0.252,0.85 0.85,0 0,0 -0.252,0.623v1.75h-1.75a0.85,0.85 0,0 0,-0.623 0.252A0.85,0.85 0,0 0,7 10.5q0,0.372 0.252,0.623a0.85,0.85 0,0 0,0.623 0.252"
android:fillColor="#FF000000"/>
</group>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.5,9.5q0.425,0 0.713,0.287 0.288,0.288 0.287,0.713a0.97,0.97 0,0 1,-0.287 0.713,0.97 0.97,0 0,1 -0.713,0.287h-6a0.97,0.97 0,0 1,-0.713 -0.287,0.97 0.97,0 0,1 -0.287,-0.713q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,7.5 9.5z"
android:fillColor="#FF000000"/>
<path
android:pathData="M10.5,3a7.5,7.5 0,0 1,5.963 12.049l3.244,3.244a1,1 0,1 1,-1.414 1.414l-3.244,-3.244A7.5,7.5 0,1 1,10.5 3m0,2a5.5,5.5 0,1 0,0 11,5.5 5.5,0 0,0 0,-11"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -6,20 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("java-library")
id("com.android.lint")
alias(libs.plugins.kotlin.jvm)
}
java {
sourceCompatibility = Versions.javaVersion
targetCompatibility = Versions.javaVersion
}
kotlin {
jvmToolchain {
languageVersion = Versions.javaLanguageVersion
}
id("io.element.jvm-library")
}
dependencies {

View file

@ -11,6 +11,19 @@ package io.element.android.libraries.dateformatter.api
import java.util.Locale
import kotlin.time.Duration
/**
* Formats a duration in a localized, human-readable way.
* Uses the largest appropriate unit (hours, minutes, or seconds).
*
* Examples (in English):
* - 2 hours 30 minutes "3 hours" (rounded)
* - 45 minutes "45 minutes"
* - 30 seconds "30 seconds"
*/
interface DurationFormatter {
fun format(duration: Duration): String
}
/**
* Convert milliseconds to human readable duration.
* Hours in 1 digit or more.

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import android.icu.text.MeasureFormat
import android.icu.text.MeasureFormat.FormatWidth
import android.icu.util.Measure
import android.icu.util.MeasureUnit
import android.text.format.DateUtils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.libraries.dateformatter.api.DurationFormatter
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* Formats durations in a localized, human-readable way using Android's MeasureFormat.
*
* Uses WIDE format for readability (e.g., "5 hours", "3 minutes", "10 seconds").
* Rounds to the nearest unit for cleaner display.
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, binding = binding<DurationFormatter>())
class DefaultDurationFormatter(
localeChangeObserver: LocaleChangeObserver,
locale: Locale,
) : DurationFormatter, LocaleChangeListener {
init {
localeChangeObserver.addListener(this)
}
// Cache formatter, recreate only on locale change
private var formatter: MeasureFormat = MeasureFormat.getInstance(locale, FormatWidth.WIDE)
override fun onLocaleChange() {
formatter = MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE)
}
override fun format(duration: Duration): String {
val millis = duration.inWholeMilliseconds
return when {
duration >= 1.hours -> {
// Round to nearest hour (add 30 minutes before dividing)
val hours = ((millis + 30 * DateUtils.MINUTE_IN_MILLIS) / DateUtils.HOUR_IN_MILLIS).toInt()
formatter.format(Measure(hours, MeasureUnit.HOUR))
}
duration >= 1.minutes -> {
// Round to nearest minute (add 30 seconds before dividing)
val minutes = ((millis + 30 * DateUtils.SECOND_IN_MILLIS) / DateUtils.MINUTE_IN_MILLIS).toInt()
formatter.format(Measure(minutes, MeasureUnit.MINUTE))
}
else -> {
// Round to nearest second (add 500ms before dividing)
val seconds = ((millis + 500) / DateUtils.SECOND_IN_MILLIS).toInt()
formatter.format(Measure(seconds, MeasureUnit.SECOND))
}
}
}
}

View file

@ -0,0 +1,133 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import java.util.Locale
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@RunWith(AndroidJUnit4::class)
@Config(qualifiers = "en", sdk = [Build.VERSION_CODES.TIRAMISU])
class DefaultDurationFormatterTest {
private fun createDurationFormatter(): DefaultDurationFormatter {
return DefaultDurationFormatter(
localeChangeObserver = {},
locale = Locale.US,
)
}
@Test
fun `test zero duration`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(0.seconds)).isEqualTo("0 seconds")
}
@Test
fun `test 1 second`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.seconds)).isEqualTo("1 second")
}
@Test
fun `test 30 seconds`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(30.seconds)).isEqualTo("30 seconds")
}
@Test
fun `test 59 seconds`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(59.seconds)).isEqualTo("59 seconds")
}
@Test
fun `test 1 minute`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.minutes)).isEqualTo("1 minute")
}
@Test
fun `test 1 minute 29 seconds rounds to 1 minute`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.minutes + 29.seconds)).isEqualTo("1 minute")
}
@Test
fun `test 1 minute 30 seconds rounds to 2 minutes`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.minutes + 30.seconds)).isEqualTo("2 minutes")
}
@Test
fun `test 45 minutes`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(45.minutes)).isEqualTo("45 minutes")
}
@Test
fun `test 59 minutes`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(59.minutes)).isEqualTo("59 minutes")
}
@Test
fun `test 1 hour`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.hours)).isEqualTo("1 hour")
}
@Test
fun `test 1 hour 29 minutes rounds to 1 hour`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.hours + 29.minutes)).isEqualTo("1 hour")
}
@Test
fun `test 1 hour 30 minutes rounds to 2 hours`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.hours + 30.minutes)).isEqualTo("2 hours")
}
@Test
fun `test 2 hours 30 minutes rounds to 3 hours`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(2.hours + 30.minutes)).isEqualTo("3 hours")
}
@Test
fun `test 5 hours`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(5.hours)).isEqualTo("5 hours")
}
@Test
fun `test 24 hours`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(24.hours)).isEqualTo("24 hours")
}
@Test
fun `test rounding at seconds threshold - 499ms rounds to 0 seconds`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(499.milliseconds)).isEqualTo("0 seconds")
}
@Test
fun `test rounding at seconds threshold - 500ms rounds to 1 second`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(500.milliseconds)).isEqualTo("1 second")
}
}

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.test
import io.element.android.libraries.dateformatter.api.DurationFormatter
import kotlin.time.Duration
class FakeDurationFormatter(
private val formatLambda: (Duration) -> String = { it.toString() },
) : DurationFormatter {
override fun format(duration: Duration): String {
return formatLambda(duration)
}
}

View file

@ -8,14 +8,19 @@
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.BorderStroke
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.Badge
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1960-491
*/
object MatrixBadgeAtom {
data class MatrixBadgeData(
val text: String,
@ -40,6 +45,12 @@ object MatrixBadgeAtom {
Type.Negative -> ElementTheme.colors.bgCriticalSubtle
Type.Info -> ElementTheme.colors.bgBadgeInfo
}
val borderStroke = when (data.type) {
Type.Positive -> null
Type.Neutral -> BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary)
Type.Negative -> null
Type.Info -> null
}
val textColor = when (data.type) {
Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.textPrimary
@ -58,6 +69,7 @@ object MatrixBadgeAtom {
backgroundColor = backgroundColor,
iconColor = iconColor,
textColor = textColor,
borderStroke = borderStroke,
)
}
}

View file

@ -26,6 +26,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.colors.gradientCriticalColors
import io.element.android.libraries.designsystem.colors.gradientInfoColors
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarType
@ -38,13 +40,16 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2392-6721
*/
@Composable
fun ComposerAlertMolecule(
avatar: AvatarData?,
content: AnnotatedString,
onSubmitClick: () -> Unit,
modifier: Modifier = Modifier,
level: ComposerAlertLevel = ComposerAlertLevel.Default,
level: ComposerAlertLevel = ComposerAlertLevel.Info,
showIcon: Boolean = false,
submitText: String = stringResource(CommonStrings.action_ok),
) {
@ -52,20 +57,12 @@ fun ComposerAlertMolecule(
modifier.fillMaxWidth()
) {
val lineColor = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.borderInfoSubtle
ComposerAlertLevel.Info -> ElementTheme.colors.borderInfoSubtle
ComposerAlertLevel.Critical -> ElementTheme.colors.borderCriticalSubtle
}
val startColor = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.bgInfoSubtle
ComposerAlertLevel.Info -> ElementTheme.colors.bgInfoSubtle
ComposerAlertLevel.Critical -> ElementTheme.colors.bgCriticalSubtle
}
val textColor = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.textPrimary
ComposerAlertLevel.Info -> ElementTheme.colors.textInfoPrimary
ComposerAlertLevel.Info -> ElementTheme.colors.textPrimary
ComposerAlertLevel.Critical -> ElementTheme.colors.textCriticalPrimary
}
@ -75,12 +72,13 @@ fun ComposerAlertMolecule(
.height(1.dp)
.background(lineColor)
)
val brush = Brush.verticalGradient(
listOf(startColor, ElementTheme.colors.bgCanvasDefault),
)
val gradientColors = when (level) {
ComposerAlertLevel.Info -> gradientInfoColors()
ComposerAlertLevel.Critical -> gradientCriticalColors()
}
Box(
modifier = Modifier
.background(brush)
.background(Brush.verticalGradient(gradientColors))
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) {
Column(
@ -96,12 +94,10 @@ fun ComposerAlertMolecule(
)
} else if (showIcon) {
val icon = when (level) {
ComposerAlertLevel.Default -> CompoundIcons.Info()
ComposerAlertLevel.Info -> CompoundIcons.Info()
ComposerAlertLevel.Critical -> CompoundIcons.Error()
}
val iconTint = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.iconPrimary
ComposerAlertLevel.Info -> ElementTheme.colors.iconInfoPrimary
ComposerAlertLevel.Critical -> ElementTheme.colors.iconCriticalPrimary
}
@ -131,7 +127,6 @@ fun ComposerAlertMolecule(
}
enum class ComposerAlertLevel {
Default,
Info,
Critical
}

View file

@ -21,7 +21,6 @@ internal data class ComposerAlertMoleculeParams(
internal class ComposerAlertMoleculeParamsProvider : PreviewParameterProvider<ComposerAlertMoleculeParams> {
private val allLevels = sequenceOf(
ComposerAlertLevel.Default,
ComposerAlertLevel.Info,
ComposerAlertLevel.Critical
)

View file

@ -38,8 +38,11 @@ fun gradientSubtleColors(): List<Color> = listOf(
fun gradientInfoColors(): List<Color> = listOf(
ElementTheme.colors.gradientInfoStop1,
ElementTheme.colors.gradientInfoStop2,
ElementTheme.colors.gradientInfoStop3,
ElementTheme.colors.gradientInfoStop4,
ElementTheme.colors.gradientInfoStop5,
ElementTheme.colors.gradientInfoStop6,
)
@Composable
@ReadOnlyComposable
fun gradientCriticalColors(): List<Color> = listOf(
ElementTheme.colors.gradientCriticalStop1,
ElementTheme.colors.gradientCriticalStop2,
)

View file

@ -0,0 +1,419 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withSave
import coil3.Image
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.asImage
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowHardware
import coil3.toBitmap
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
private val PIN_WIDTH = 42.dp
private val PIN_HEIGHT = PIN_WIDTH * 1.2f
private val AVATAR_SIZE = PIN_WIDTH - 10.dp
private val CONTENT_OFFSET = 5.dp
private val DOT_RADIUS = 6.dp
private val STROKE_WIDTH = 1.dp
/**
* Variants of location pin markers.
*/
@Immutable
sealed interface PinVariant {
data class UserLocation(
val avatarData: AvatarData,
val isLive: Boolean,
) : PinVariant
data object PinnedLocation : PinVariant
data object StaleLocation : PinVariant
}
/**
* A location pin composable that supports multiple variants.
*
* Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev
*/
@Composable
fun LocationPin(
variant: PinVariant,
modifier: Modifier = Modifier,
) {
val image = rememberLocationPinBitmap(variant)
Canvas(modifier = modifier.size(PIN_WIDTH, PIN_HEIGHT)) {
if (image != null) {
drawImage(image)
}
}
}
/**
* Renders a location pin to an [ImageBitmap] using Canvas operations.
* @param variant The pin variant to render
* @return The rendered [ImageBitmap], or null if still loading
*/
@Composable
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
val context = LocalContext.current
val density = LocalDensity.current
val imageLoader = SingletonImageLoader.get(context)
val colors = pinColors(variant)
val cacheKey = rememberCacheKey(variant)
return produceState<ImageBitmap?>(initialValue = null, cacheKey) {
val memoryCacheKey = MemoryCache.Key(cacheKey)
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
if (cached != null) {
value = cached.image.toBitmap().asImageBitmap()
} else {
val dimensions = PinDimensions(density)
val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader)
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
value = bitmap.asImageBitmap()
}
}.value
}
@Composable
private fun pinColors(variant: PinVariant): PinColors {
return when (variant) {
is PinVariant.UserLocation -> {
val avatarColors = AvatarColorsProvider.provide(variant.avatarData.id)
if (variant.isLive) {
PinColors(
fill = ElementTheme.colors.iconAccentPrimary,
stroke = Color.Transparent,
dot = Color.Transparent,
avatarStroke = ElementTheme.colors.bgCanvasDefault,
avatarBackground = avatarColors.background,
avatarForeground = avatarColors.foreground,
)
} else {
PinColors(
fill = ElementTheme.colors.bgCanvasDefault,
stroke = ElementTheme.colors.iconQuaternaryAlpha,
dot = Color.Transparent,
avatarStroke = ElementTheme.colors.iconQuaternaryAlpha,
avatarBackground = avatarColors.background,
avatarForeground = avatarColors.foreground,
)
}
}
PinVariant.PinnedLocation -> PinColors(
fill = ElementTheme.colors.bgCanvasDefault,
stroke = ElementTheme.colors.iconSecondaryAlpha,
avatarStroke = Color.Transparent,
avatarBackground = Color.Transparent,
avatarForeground = Color.Transparent,
dot = ElementTheme.colors.iconPrimary,
)
PinVariant.StaleLocation -> PinColors(
fill = ElementTheme.colors.bgSubtleSecondary,
stroke = ElementTheme.colors.iconDisabled,
avatarStroke = Color.Transparent,
avatarBackground = Color.Transparent,
avatarForeground = Color.Transparent,
dot = ElementTheme.colors.iconDisabled,
)
}
}
/**
* Color configuration for rendering a location pin.
*/
private data class PinColors(
val fill: Color,
val stroke: Color,
val dot: Color,
val avatarStroke: Color,
val avatarBackground: Color,
val avatarForeground: Color,
)
/**
* Pre-calculated pixel dimensions for rendering a location pin.
*/
private class PinDimensions(density: Density) {
val pinWidth = with(density) { PIN_WIDTH.toPx() }
val pinHeight = with(density) { PIN_HEIGHT.toPx() }
val avatarSize: Float = with(density) { AVATAR_SIZE.toPx() }
val avatarOffset: Float = with(density) { CONTENT_OFFSET.toPx() }
val dotRadius: Float = with(density) { DOT_RADIUS.toPx() }
val strokeWidth: Float = with(density) { STROKE_WIDTH.toPx() }
}
/**
* Renders location pins to bitmaps using Canvas operations.
* Uses Coil for avatar loading.
* Paint objects are shared across all renders.
*/
private object LocationPinRenderer {
// Shared Paint objects to avoid allocations
private val fillPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
private val strokePaint = Paint().apply {
style = Paint.Style.STROKE
isAntiAlias = true
}
private val textPaint = Paint().apply {
textAlign = Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
/**
* Renders a pin variant to bitmap. Suspending for async avatar loading.
*/
suspend fun renderPin(
variant: PinVariant,
colors: PinColors,
dimensions: PinDimensions,
context: Context,
imageLoader: ImageLoader,
): Bitmap {
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
val canvas = Canvas(bitmap)
canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
when (variant) {
is PinVariant.UserLocation -> {
val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
canvas.drawAvatar(
avatarImage = avatarImage,
avatarData = variant.avatarData,
borderColor = colors.avatarStroke,
backgroundColor = colors.avatarBackground,
foregroundColor = colors.avatarForeground,
dimensions = dimensions,
)
}
PinVariant.PinnedLocation,
PinVariant.StaleLocation -> canvas.drawDot(colors.dot, dimensions)
}
return bitmap
}
private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color, dimensions: PinDimensions) {
val path = createPinPath(dimensions)
fillPaint.color = fillColor.toArgb()
drawPath(path, fillPaint)
strokePaint.color = strokeColor.toArgb()
strokePaint.strokeWidth = dimensions.strokeWidth
drawPath(path, strokePaint)
}
/**
* Updates the teardrop-shaped pin path to match dimensions.
* Based on SVG path with dimensions 40x48 (ratio 1:1.2).
*/
private fun createPinPath(dimensions: PinDimensions): Path {
val svgWidth = 40f
val svgHeight = 48f
val inset = dimensions.strokeWidth / 2
val scaleX = (dimensions.pinWidth - dimensions.strokeWidth) / svgWidth
val scaleY = (dimensions.pinHeight - dimensions.strokeWidth) / svgHeight
val path = Path().apply {
moveTo(20f, 48f)
cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f)
cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f)
cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f)
cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f)
cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f)
cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f)
cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f)
cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f)
cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f)
cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f)
cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f)
cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f)
cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f)
cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f)
cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f)
cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f)
close()
}
val matrix = Matrix().apply {
setScale(scaleX, scaleY)
postTranslate(inset, inset)
}
path.transform(matrix)
return path
}
private suspend fun loadAvatarImage(
avatarData: AvatarData,
context: Context,
imageLoader: ImageLoader,
): Image? {
val request = ImageRequest.Builder(context)
.data(avatarData)
// Disable hardware rendering for Canvas
.allowHardware(false)
.build()
return imageLoader.execute(request).image
}
private fun Canvas.drawAvatar(
avatarImage: Image?,
avatarData: AvatarData,
borderColor: Color,
backgroundColor: Color,
foregroundColor: Color,
dimensions: PinDimensions,
) {
val centerX = dimensions.pinWidth / 2
val avatarY = dimensions.avatarOffset
val avatarRadius = dimensions.avatarSize / 2
withSave {
if (avatarImage != null) {
val bitmap = avatarImage.toBitmap()
// Calculate centered square crop (ContentScale.Crop behavior)
val srcSize = minOf(bitmap.width, bitmap.height)
val srcX = (bitmap.width - srcSize) / 2
val srcY = (bitmap.height - srcSize) / 2
val srcRect = Rect(srcX, srcY, srcX + srcSize, srcY + srcSize)
val destRect = RectF(
centerX - avatarRadius,
avatarY,
centerX + avatarRadius,
avatarY + dimensions.avatarSize
)
val clipPath = Path().apply {
addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW)
}
clipPath(clipPath)
drawBitmap(bitmap, srcRect, destRect, null)
} else {
drawInitialLetterAvatar(
initialLetter = avatarData.initialLetter,
centerX = centerX,
centerY = avatarY + avatarRadius,
radius = avatarRadius,
foreground = foregroundColor.toArgb(),
background = backgroundColor.toArgb()
)
}
}
strokePaint.color = borderColor.toArgb()
strokePaint.strokeWidth = dimensions.strokeWidth
drawCircle(centerX, avatarY + avatarRadius, avatarRadius, strokePaint)
}
private fun Canvas.drawInitialLetterAvatar(
initialLetter: String,
centerX: Float,
centerY: Float,
radius: Float,
foreground: Int,
background: Int,
) {
fillPaint.color = background
drawCircle(centerX, centerY, radius, fillPaint)
textPaint.color = foreground
textPaint.textSize = radius * 1.2f
val textBounds = Rect()
textPaint.getTextBounds(initialLetter, 0, 1, textBounds)
val textY = centerY + textBounds.height() / 2f
drawText(initialLetter, centerX, textY, textPaint)
}
private fun Canvas.drawDot(dotColor: Color, dimensions: PinDimensions) {
if (dotColor == Color.Transparent) return
val centerX = dimensions.pinWidth / 2
val centerY = dimensions.avatarOffset + dimensions.avatarSize / 2
fillPaint.color = dotColor.toArgb()
drawCircle(centerX, centerY, dimensions.dotRadius, fillPaint)
}
}
@PreviewsDayNight
@Composable
internal fun LocationPinPreview() = ElementPreview {
val sampleAvatarData = AvatarData(
id = "@alice:matrix.org",
name = "Alice",
url = null,
size = AvatarSize.SelectedUser
)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
LocationPin(
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false),
)
LocationPin(
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
LocationPin(
variant = PinVariant.PinnedLocation,
)
LocationPin(
variant = PinVariant.StaleLocation,
)
}
}
}
@Composable
private fun rememberCacheKey(variant: PinVariant): String {
val isLightTheme = ElementTheme.isLightTheme
val density = LocalDensity.current.density
return remember(isLightTheme, density, variant) {
val pinVariant = when (variant) {
PinVariant.PinnedLocation -> "pin_pinned"
PinVariant.StaleLocation -> "pin_stale"
is PinVariant.UserLocation -> "pin_user_${variant.avatarData.id}_${variant.isLive}"
}
"${pinVariant}_{$isLightTheme}_{$density}"
}
}

View file

@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
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.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@Composable
fun PinIcon(
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.background(ElementTheme.colors.bgSubtlePrimary)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.width(22.dp),
resourceId = R.drawable.pin,
contentDescription = null,
tint = Color.Unspecified,
)
}
}
@PreviewsDayNight
@Composable
internal fun PinIconPreview() = ElementPreview {
PinIcon()
}

View file

@ -46,7 +46,8 @@ enum class AvatarSize(val dp: Dp) {
RoomInviteItem(52.dp),
InviteSender(16.dp),
EditRoomDetails(68.dp),
EditRoomDetails(64.dp),
EditSpaceDetails(96.dp),
RoomListManageUser(96.dp),
NotificationsOptIn(32.dp),
@ -75,6 +76,6 @@ enum class AvatarSize(val dp: Dp) {
SpaceMember(24.dp),
LeaveSpaceRoom(32.dp),
SelectParentSpace(32.dp),
AccountItem(32.dp),
LocationPin(32.dp)
}

View file

@ -42,6 +42,7 @@ fun ListDialog(
enabled: Boolean = true,
applyPaddingToContents: Boolean = true,
destructiveSubmit: Boolean = false,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp),
listItems: LazyListScope.() -> Unit,
) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@ -67,6 +68,7 @@ fun ListDialog(
listItems = listItems,
applyPaddingToContents = applyPaddingToContents,
destructiveSubmit = destructiveSubmit,
verticalArrangement = verticalArrangement,
)
}
}
@ -82,6 +84,7 @@ private fun ListDialogContent(
enabled: Boolean,
applyPaddingToContents: Boolean,
destructiveSubmit: Boolean,
verticalArrangement: Arrangement.Vertical,
subtitle: @Composable (() -> Unit)? = null,
) {
SimpleAlertDialogContent(
@ -99,7 +102,7 @@ private fun ListDialogContent(
val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp
LazyColumn(
modifier = Modifier.padding(horizontal = horizontalPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = verticalArrangement,
) { listItems() }
}
}
@ -126,6 +129,7 @@ internal fun ListDialogContentPreview() {
enabled = true,
destructiveSubmit = false,
applyPaddingToContents = true,
verticalArrangement = Arrangement.spacedBy(16.dp),
)
}
}

View file

@ -14,7 +14,6 @@ import io.element.android.libraries.designsystem.R
// All the icons should be defined in Compound.
internal val iconsOther = listOf(
R.drawable.ic_notification,
R.drawable.ic_stop,
R.drawable.pin,
R.drawable.ic_winner,
)

View file

@ -20,9 +20,9 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.AndroidPaint
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
@ -55,10 +55,10 @@ fun Modifier.blurredShapeShadow(
addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx())))
}
val frameworkPaint = android.graphics.Paint()
// Draw the blurred shadow, then cut out the shape from it
clipPath(path, ClipOp.Difference) {
val paint = Paint()
val frameworkPaint = paint.asFrameworkPaint()
val paint = AndroidPaint(frameworkPaint)
if (blurRadius != 0.dp) {
frameworkPaint.maskFilter = BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL)
}

View file

@ -54,7 +54,7 @@ fun ModalBottomSheet(
tonalElevation: Dp = if (ElementTheme.isLightTheme) 0.dp else BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets },
content: @Composable ColumnScope.() -> Unit,
) {
val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,16V8C6,7.45 6.196,6.979 6.588,6.588C6.979,6.196 7.45,6 8,6H16C16.55,6 17.021,6.196 17.413,6.588C17.804,6.979 18,7.45 18,8V16C18,16.55 17.804,17.021 17.413,17.413C17.021,17.804 16.55,18 16,18H8C7.45,18 6.979,17.804 6.588,17.413C6.196,17.021 6,16.55 6,16Z"
android:fillColor="#ffffff"/>
</vector>

View file

@ -7,8 +7,7 @@
*/
plugins {
alias(libs.plugins.kotlin.jvm)
id("com.android.lint")
id("io.element.jvm-library")
}
dependencies {

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
@ -115,6 +116,10 @@ class DefaultRoomLatestEventFormatter(
val message = sp.getString(CommonStrings.common_unsupported_event)
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is LiveLocationContent -> {
val message = sp.getString(CommonStrings.common_shared_location)
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
}?.take(DEFAULT_SAFE_LENGTH)

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
@ -69,6 +70,7 @@ class DefaultTimelineEventFormatter(
is MessageContent,
is FailedToParseMessageLikeContent,
is FailedToParseStateContent,
is LiveLocationContent,
is UnknownContent -> {
if (buildMeta.isDebuggable) {
error("You should not use this formatter for this event content: $content")

View file

@ -13,6 +13,7 @@
<string name="state_event_room_avatar_changed_by_you">"Вие променихте снимката на стаята"</string>
<string name="state_event_room_avatar_removed">"%1$s премахна снимката на стаята"</string>
<string name="state_event_room_avatar_removed_by_you">"Вие премахнахте снимката на стаята"</string>
<string name="state_event_room_ban_by_you">"Вие забранихте %1$s"</string>
<string name="state_event_room_created">"%1$s създаде стаята"</string>
<string name="state_event_room_created_by_you">"Вие създадохте стаята"</string>
<string name="state_event_room_invite">"%1$s покани %2$s"</string>
@ -58,5 +59,6 @@
<string name="state_event_room_topic_removed">"%1$s премахна темата на стаята"</string>
<string name="state_event_room_topic_removed_by_you">"Вие премахнахте темата на стаята"</string>
<string name="state_event_room_unban">"%1$s премахна забраната на %2$s"</string>
<string name="state_event_room_unban_by_you">"Вие отблокирахте %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s направи неизвестна промяна в членството си"</string>
</resources>

View file

@ -1,73 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(изображение тоже было изменено)"</string>
<string name="state_event_avatar_url_changed">"%1$s сменил своё изображение"</string>
<string name="state_event_avatar_url_changed_by_you">"Вы сменили изображение профиля"</string>
<string name="state_event_demoted_to_member">"%1$s был понижен до участника"</string>
<string name="state_event_demoted_to_moderator">"%1$s был понижен до модератора"</string>
<string name="state_event_display_name_changed_from">"%1$s изменил свое отображаемое имя с %2$s на %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Вы изменили свое отображаемое имя с %1$s на %2$s"</string>
<string name="state_event_display_name_removed">"%1$s удалил свое отображаемое имя (оно было %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Вы удалили свое отображаемое имя (оно было %1$s)"</string>
<string name="state_event_display_name_set">"%1$s установили свое отображаемое имя на %2$s"</string>
<string name="state_event_display_name_set_by_you">"Вы установили отображаемое имя на %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s был повышен до уровня администратора"</string>
<string name="state_event_promoted_to_moderator">"%1$s был повышен до модератора"</string>
<string name="state_event_room_avatar_changed">"%1$s изменил изображение комнаты"</string>
<string name="state_event_room_avatar_changed_by_you">"Вы изменили изображение комнаты"</string>
<string name="state_event_room_avatar_removed">"%1$s удалил изображение комнаты"</string>
<string name="state_event_room_avatar_removed_by_you">"Вы удалили изображение комнаты"</string>
<string name="state_event_room_ban">"%1$s заблокировал %2$s"</string>
<string name="state_event_avatar_changed_too">"(аватар тоже был изменен)"</string>
<string name="state_event_avatar_url_changed">"%1$s сменил(а) аватар"</string>
<string name="state_event_avatar_url_changed_by_you">"Вы сменили аватар"</string>
<string name="state_event_demoted_to_member">"%1$s был(а) понижен до участника"</string>
<string name="state_event_demoted_to_moderator">"%1$s был(а) понижен до модератора"</string>
<string name="state_event_display_name_changed_from">"%1$s изменил(а) имя с %2$s на %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Вы изменили имя с %1$s на %2$s"</string>
<string name="state_event_display_name_removed">"%1$s (ранее %2$s) удалил(а) имя"</string>
<string name="state_event_display_name_removed_by_you">"Вы удалили свое имя (ранее %1$s)"</string>
<string name="state_event_display_name_set">"%1$s изменил(а) имя на %2$s"</string>
<string name="state_event_display_name_set_by_you">"Вы изменили имя на %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s повышен(а) до администратора"</string>
<string name="state_event_promoted_to_moderator">"%1$s повышен(а) до модератора"</string>
<string name="state_event_room_avatar_changed">"%1$s изменил(а) аватар комнаты"</string>
<string name="state_event_room_avatar_changed_by_you">"Вы изменили аватар комнаты"</string>
<string name="state_event_room_avatar_removed">"%1$s удалил(а) аватар комнаты"</string>
<string name="state_event_room_avatar_removed_by_you">"Вы удалили аватар комнаты"</string>
<string name="state_event_room_ban">"%1$s заблокировал(а) %2$s"</string>
<string name="state_event_room_ban_by_you">"Вы заблокировали %1$s"</string>
<string name="state_event_room_ban_by_you_with_reason">"Вы заблокировали %1$s: %2$s"</string>
<string name="state_event_room_ban_with_reason">"%1$s заблокирован %2$s: %3$s"</string>
<string name="state_event_room_created">"%1$s создал комнату"</string>
<string name="state_event_room_ban_with_reason">"%1$s заблокировал(а) %2$s: %3$s"</string>
<string name="state_event_room_created">"%1$s создал(а) комнату"</string>
<string name="state_event_room_created_by_you">"Вы создали комнату"</string>
<string name="state_event_room_invite">"%1$s пригласил %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s принял приглашение"</string>
<string name="state_event_room_invite">"%1$s пригласил(а) %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s принял(а) приглашение"</string>
<string name="state_event_room_invite_accepted_by_you">"Вы приняли приглашение"</string>
<string name="state_event_room_invite_by_you">"Вы пригласили %1$s"</string>
<string name="state_event_room_invite_you">"Пользователь %1$s пригласил вас"</string>
<string name="state_event_room_join">"%1$s присоединился к комнате"</string>
<string name="state_event_room_invite_you">"%1$s пригласил(а) вас"</string>
<string name="state_event_room_join">"%1$s присоединился(ась)"</string>
<string name="state_event_room_join_by_you">"Вы присоединились к комнате"</string>
<string name="state_event_room_knock">"%1$s хочет присоединиться"</string>
<string name="state_event_room_knock_accepted">"%1$s разрешил %2$s присоединиться"</string>
<string name="state_event_room_knock_accepted">"%1$s разрешил(а) %2$s присоединиться"</string>
<string name="state_event_room_knock_accepted_by_you">"Вы разрешили %1$s присоединиться"</string>
<string name="state_event_room_knock_by_you">"Вы запросили присоединение"</string>
<string name="state_event_room_knock_denied">"%1$s отклонил запрос %2$s на присоединение"</string>
<string name="state_event_room_knock_by_you">"Вы отправили запрос на присоединение"</string>
<string name="state_event_room_knock_denied">"%1$s отклонил(а) запрос %2$s на присоединение"</string>
<string name="state_event_room_knock_denied_by_you">"Вы отклонили запрос %1$s на присоединение"</string>
<string name="state_event_room_knock_denied_you">"%1$s отклонил ваш запрос на присоединение"</string>
<string name="state_event_room_knock_retracted">"%1$s больше не заинтересован в присоединении"</string>
<string name="state_event_room_knock_retracted_by_you">"Вы отменили запрос на присоединение"</string>
<string name="state_event_room_leave">"%1$s покинул комнату"</string>
<string name="state_event_room_knock_denied_you">"%1$s отклонил(а) ваш запрос на присоединение"</string>
<string name="state_event_room_knock_retracted">"%1$s отозвал(а) запрос на присоединение"</string>
<string name="state_event_room_knock_retracted_by_you">"Вы отозвали запрос на присоединение"</string>
<string name="state_event_room_leave">"%1$s покинул(а) комнату"</string>
<string name="state_event_room_leave_by_you">"Вы покинули комнату"</string>
<string name="state_event_room_name_changed">"%1$s изменил название комнаты на: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Вы изменили название комнаты на: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s удалил название комнаты"</string>
<string name="state_event_room_name_removed_by_you">"Вы удалили название комнаты"</string>
<string name="state_event_room_none">"%1$s ничего не изменил"</string>
<string name="state_event_room_none_by_you">"Вы не внесли никаких изменений"</string>
<string name="state_event_room_pinned_events_changed">"%1$s изменил закрепленные сообщения"</string>
<string name="state_event_room_name_changed">"%1$s изменил(а) название на %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Вы изменили название на %1$s"</string>
<string name="state_event_room_name_removed">"%1$s удалил(а) название"</string>
<string name="state_event_room_name_removed_by_you">"Вы удалили название"</string>
<string name="state_event_room_none">"%1$s не внес(ла) изменений"</string>
<string name="state_event_room_none_by_you">"Вы не внесли изменений"</string>
<string name="state_event_room_pinned_events_changed">"%1$s изменил(а) закрепленные сообщения"</string>
<string name="state_event_room_pinned_events_changed_by_you">"Вы изменили закрепленные сообщения"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s закрепил сообщение"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s закрепил(а) сообщение"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"Вы закрепили сообщение"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s открепил сообщение"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s открепил(а) сообщение"</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"Вы открепили сообщение"</string>
<string name="state_event_room_reject">"%1$s отклонил приглашение"</string>
<string name="state_event_room_reject">"%1$s отклонил(а) приглашение"</string>
<string name="state_event_room_reject_by_you">"Вы отклонили приглашение"</string>
<string name="state_event_room_remove">"%1$s удалил %2$s"</string>
<string name="state_event_room_remove">"%1$s удалил(а) %2$s"</string>
<string name="state_event_room_remove_by_you">"Вы удалили %1$s"</string>
<string name="state_event_room_remove_by_you_with_reason">"Вы удалили %1$s: %2$s"</string>
<string name="state_event_room_remove_with_reason">"%1$s удален %2$s: %3$s"</string>
<string name="state_event_room_third_party_invite">"%1$s отправила приглашение %2$s присоединиться к комнате"</string>
<string name="state_event_room_third_party_invite_by_you">"Вы отправили приглашение присоединиться к комнате %1$s"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s отозвал приглашение %2$s присоединиться к комнате"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Вы отозвали приглашение %1$s присоединиться к комнате"</string>
<string name="state_event_room_topic_changed">"%1$s изменил тему на: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Вы изменили тему на: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s удалил тему комнаты"</string>
<string name="state_event_room_remove_with_reason">"%1$s удалил(а) %2$s: %3$s"</string>
<string name="state_event_room_third_party_invite">"%1$s отправил(а) приглашение %2$s"</string>
<string name="state_event_room_third_party_invite_by_you">"Вы отправили приглашение %1$s"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s отозвал приглашение %2$s"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Вы отозвали приглашение %1$s"</string>
<string name="state_event_room_topic_changed">"%1$s изменил(а) тему на %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Вы изменили тему на %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s удалил(а) тему комнаты"</string>
<string name="state_event_room_topic_removed_by_you">"Вы удалили тему комнаты"</string>
<string name="state_event_room_unban">"%1$s разблокировал %2$s"</string>
<string name="state_event_room_unban">"%1$s разблокировал(а) %2$s"</string>
<string name="state_event_room_unban_by_you">"Вы разблокировали %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s внес неизвестное изменение для своих участников"</string>
</resources>

View file

@ -145,7 +145,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
ImageMessageType(body, null, null, MediaSource("url"), null),
StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null),
LocationMessageType(body, "geo:1,2", null, null),
NoticeMessageType(body, null),
EmoteMessageType(body, null),
OtherMessageType(msgType = "a_type", body = body),

View file

@ -190,7 +190,7 @@ class DefaultRoomLatestEventFormatterTest {
ImageMessageType(body, null, null, MediaSource("url"), null),
StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null),
LocationMessageType(body, "geo:1,2", null, null),
NoticeMessageType(body, null),
EmoteMessageType(body, null),
OtherMessageType(msgType = "a_type", body = body),

View file

@ -147,4 +147,19 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
LiveLocationSharing(
key = "feature.liveLocationSharing",
title = "Live location sharing",
description = "Allow sharing live location in rooms.",
defaultValue = { false },
isFinished = false,
),
ValidateNetworkWhenSchedulingNotificationFetching(
key = "feature.validate_network_when_scheduling_notification_fetching",
title = "validate internet connectivity when scheduling notification fetching",
description = "Only fetch events for push notifications when the device has internet connectivity. " +
"Enabling this can be problematic in air-gapped environments.",
defaultValue = { true },
isFinished = false,
),
}

View file

@ -1,28 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.libraries.maplibre.compose"
kotlin {
compilerOptions {
explicitApi()
}
}
}
dependencies {
api(libs.maplibre)
api(libs.maplibre.ktx)
api(libs.maplibre.annotation)
}

View file

@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.Immutable
import org.maplibre.android.location.modes.CameraMode as InternalCameraMode
@Immutable
public enum class CameraMode {
NONE,
NONE_COMPASS,
NONE_GPS,
TRACKING,
TRACKING_COMPASS,
TRACKING_GPS,
TRACKING_GPS_NORTH;
@InternalCameraMode.Mode
internal fun toInternal(): Int = when (this) {
NONE -> InternalCameraMode.NONE
NONE_COMPASS -> InternalCameraMode.NONE_COMPASS
NONE_GPS -> InternalCameraMode.NONE_GPS
TRACKING -> InternalCameraMode.TRACKING
TRACKING_COMPASS -> InternalCameraMode.TRACKING_COMPASS
TRACKING_GPS -> InternalCameraMode.TRACKING_GPS
TRACKING_GPS_NORTH -> InternalCameraMode.TRACKING_GPS_NORTH
}
internal companion object {
fun fromInternal(@InternalCameraMode.Mode mode: Int): CameraMode = when (mode) {
InternalCameraMode.NONE -> NONE
InternalCameraMode.NONE_COMPASS -> NONE_COMPASS
InternalCameraMode.NONE_GPS -> NONE_GPS
InternalCameraMode.TRACKING -> TRACKING
InternalCameraMode.TRACKING_COMPASS -> TRACKING_COMPASS
InternalCameraMode.TRACKING_GPS -> TRACKING_GPS
InternalCameraMode.TRACKING_GPS_NORTH -> TRACKING_GPS_NORTH
else -> error("Unknown camera mode: $mode")
}
}
}

View file

@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.Immutable
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_ANIMATION
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION
/**
* Enumerates the different reasons why the map camera started to move.
*
* Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener.
*
* [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed.
*
* [UNKNOWN] is used to represent when an unsupported integer value is provided to [fromInt] - this
* may be a new constant value from the Maps SDK that isn't supported by maps-compose yet, in which
* case this library should be updated to include a new enum value for that constant.
*/
@Immutable
public enum class CameraMoveStartedReason(public val value: Int) {
UNKNOWN(-2),
NO_MOVEMENT_YET(-1),
GESTURE(REASON_API_GESTURE),
API_ANIMATION(REASON_API_ANIMATION),
DEVELOPER_ANIMATION(REASON_DEVELOPER_ANIMATION);
public companion object {
/**
* Converts from the Maps SDK [org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener]
* constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such
* [CameraMoveStartedReason] for the given [value].
*
* See https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener.
*/
public fun fromInt(value: Int): CameraMoveStartedReason {
return values().firstOrNull { it.value == value } ?: return UNKNOWN
}
}
}

View file

@ -1,179 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import android.location.Location
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.parcelize.Parcelize
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Projection
/**
* Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver].
* [init] will be called when the [CameraPositionState] is first created to configure its
* initial state.
*/
@Composable
public inline fun rememberCameraPositionState(
crossinline init: CameraPositionState.() -> Unit = {}
): CameraPositionState = rememberSaveable(saver = CameraPositionState.Saver) {
CameraPositionState().apply(init)
}
/**
* A state object that can be hoisted to control and observe the map's camera state.
* A [CameraPositionState] may only be used by a single [MapLibreMap] composable at a time
* as it reflects instance state for a single view of a map.
*
* @param position the initial camera position
* @param cameraMode the initial camera mode
*/
public class CameraPositionState(
position: CameraPosition = CameraPosition.Builder().build(),
cameraMode: CameraMode = CameraMode.NONE,
) {
/**
* Whether the camera is currently moving or not. This includes any kind of movement:
* panning, zooming, or rotation.
*/
public var isMoving: Boolean by mutableStateOf(false)
internal set
/**
* The reason for the start of the most recent camera moment, or
* [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or
* [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK.
*/
public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf(
CameraMoveStartedReason.NO_MOVEMENT_YET
)
internal set
/**
* Returns the current [Projection] to be used for converting between screen
* coordinates and lat/lng.
*/
public val projection: Projection?
get() = map?.projection
/**
* Local source of truth for the current camera position.
* While [map] is non-null this reflects the current position of [map] as it changes.
* While [map] is null it reflects the last known map position, or the last value set by
* explicitly setting [position].
*/
internal var rawPosition by mutableStateOf(position)
/**
* Current position of the camera on the map.
*/
public var position: CameraPosition
get() = rawPosition
set(value) {
synchronized(lock) {
val map = map
if (map == null) {
rawPosition = value
} else {
map.moveCamera(CameraUpdateFactory.newCameraPosition(value))
}
}
}
/**
* Local source of truth for the current camera mode.
* While [map] is non-null this reflects the current camera mode as it changes.
* While [map] is null it reflects the last known camera mode, or the last value set by
* explicitly setting [cameraMode].
*/
internal var rawCameraMode by mutableStateOf(cameraMode)
/**
* Current tracking mode of the camera.
*/
public var cameraMode: CameraMode
get() = rawCameraMode
set(value) {
synchronized(lock) {
val map = map
if (map == null) {
rawCameraMode = value
} else {
map.locationComponent.cameraMode = value.toInternal()
}
}
}
/**
* The user's last available location.
*/
public var location: Location? by mutableStateOf(null)
internal set
// Used to perform side effects thread-safely.
// Guards all mutable properties that are not `by mutableStateOf`.
private val lock = Unit
// The map currently associated with this CameraPositionState.
// Guarded by `lock`.
private var map: MapLibreMap? by mutableStateOf(null)
// The current map is set and cleared by side effect.
// There can be only one associated at a time.
internal fun setMap(map: MapLibreMap?) {
synchronized(lock) {
if (this.map == null && map == null) return
if (this.map != null && map != null) {
error("CameraPositionState may only be associated with one MapLibreMap at a time")
}
this.map = map
if (map == null) {
isMoving = false
} else {
map.moveCamera(CameraUpdateFactory.newCameraPosition(position))
map.locationComponent.cameraMode = cameraMode.toInternal()
}
}
}
public companion object {
/**
* The default saver implementation for [CameraPositionState].
*/
public val Saver: Saver<CameraPositionState, SaveableCameraPositionData> = Saver(
save = { SaveableCameraPositionData(it.position, it.cameraMode.toInternal()) },
restore = { CameraPositionState(it.position, CameraMode.fromInternal(it.cameraMode)) }
)
}
}
/** Provides the [CameraPositionState] used by the map. */
internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositionState() }
/** The current [CameraPositionState] used by the map. */
public val currentCameraPositionState: CameraPositionState
@[MapLibreMapComposable ReadOnlyComposable Composable]
get() = LocalCameraPositionState.current
@Parcelize
public data class SaveableCameraPositionData(
val position: CameraPosition,
val cameraMode: Int
) : Parcelable

View file

@ -1,39 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.Immutable
import org.maplibre.android.style.layers.Property
@Immutable
public enum class IconAnchor {
CENTER,
LEFT,
RIGHT,
TOP,
BOTTOM,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT;
@Property.ICON_ANCHOR
internal fun toInternal(): String = when (this) {
CENTER -> Property.ICON_ANCHOR_CENTER
LEFT -> Property.ICON_ANCHOR_LEFT
RIGHT -> Property.ICON_ANCHOR_RIGHT
TOP -> Property.ICON_ANCHOR_TOP
BOTTOM -> Property.ICON_ANCHOR_BOTTOM
TOP_LEFT -> Property.ICON_ANCHOR_TOP_LEFT
TOP_RIGHT -> Property.ICON_ANCHOR_TOP_RIGHT
BOTTOM_LEFT -> Property.ICON_ANCHOR_BOTTOM_LEFT
BOTTOM_RIGHT -> Property.ICON_ANCHOR_BOTTOM_RIGHT
}
}

View file

@ -1,57 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.AbstractApplier
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
import org.maplibre.android.plugins.annotation.SymbolManager
internal interface MapNode {
fun onAttached() {}
fun onRemoved() {}
fun onCleared() {}
}
private object MapNodeRoot : MapNode
internal class MapApplier(
val map: MapLibreMap,
val style: Style,
val symbolManager: SymbolManager,
) : AbstractApplier<MapNode>(MapNodeRoot) {
private val decorations = mutableListOf<MapNode>()
override fun onClear() {
symbolManager.deleteAll()
decorations.forEach { it.onCleared() }
decorations.clear()
}
override fun insertBottomUp(index: Int, instance: MapNode) {
decorations.add(index, instance)
instance.onAttached()
}
override fun insertTopDown(index: Int, instance: MapNode) {
// insertBottomUp is preferred
}
override fun move(from: Int, to: Int, count: Int) {
decorations.move(from, to, count)
}
override fun remove(index: Int, count: Int) {
repeat(count) {
decorations[index + it].onRemoved()
}
decorations.remove(index, count)
}
}

View file

@ -1,247 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.awaitCancellation
import org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
import org.maplibre.android.plugins.annotation.SymbolManager
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* A compose container for a MapLibre [MapView].
*
* Heavily inspired by https://github.com/googlemaps/android-maps-compose
*
* @param styleUri a URI where to asynchronously fetch a style for the map
* @param modifier Modifier to be applied to the MapLibreMap
* @param images images added to the map's style to be later used with [Symbol]
* @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's
* camera state
* @param uiSettings the [MapUiSettings] to be used for UI-specific settings on the map
* @param symbolManagerSettings the [MapSymbolManagerSettings] to be used for symbol manager settings
* @param locationSettings the [MapLocationSettings] to be used for location settings
* @param content the content of the map
*/
@Composable
public fun MapLibreMap(
styleUri: String,
modifier: Modifier = Modifier,
images: ImmutableMap<String, Int> = persistentMapOf(),
cameraPositionState: CameraPositionState = rememberCameraPositionState(),
uiSettings: MapUiSettings = DefaultMapUiSettings,
symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings,
locationSettings: MapLocationSettings = DefaultMapLocationSettings,
content: (@Composable @MapLibreMapComposable () -> Unit)? = null,
) {
// When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) {
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
Box(
modifier = modifier.background(Color.DarkGray)
) {
Text("[Map]", modifier = Modifier.align(Alignment.Center))
}
return
}
val context = LocalContext.current
val mapView = remember {
MapLibre.getInstance(context)
MapView(context)
}
@Suppress("ModifierReused")
AndroidView(modifier = modifier, factory = { mapView })
MapLifecycle(mapView)
// rememberUpdatedState and friends are used here to make these values observable to
// the subcomposition without providing a new content function each recomposition
val currentCameraPositionState by rememberUpdatedState(cameraPositionState)
val currentUiSettings by rememberUpdatedState(uiSettings)
val currentMapLocationSettings by rememberUpdatedState(locationSettings)
val currentSymbolManagerSettings by rememberUpdatedState(symbolManagerSettings)
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
LaunchedEffect(styleUri, images) {
disposingComposition {
parentComposition.newComposition(
context = context,
mapView = mapView,
styleUri = styleUri,
images = images,
) {
MapUpdater(
cameraPositionState = currentCameraPositionState,
uiSettings = currentUiSettings,
locationSettings = currentMapLocationSettings,
symbolManagerSettings = currentSymbolManagerSettings,
)
CompositionLocalProvider(
LocalCameraPositionState provides cameraPositionState,
) {
currentContent?.invoke()
}
}
}
}
}
private suspend inline fun disposingComposition(factory: () -> Composition) {
val composition = factory()
try {
awaitCancellation()
} finally {
composition.dispose()
}
}
private suspend inline fun CompositionContext.newComposition(
context: Context,
mapView: MapView,
styleUri: String,
images: ImmutableMap<String, Int>,
noinline content: @Composable () -> Unit
): Composition {
val map = mapView.awaitMap()
val style = map.awaitStyle(context, styleUri, images)
val symbolManager = SymbolManager(mapView, map, style)
return Composition(
MapApplier(map, style, symbolManager),
this
).apply {
setContent(content)
}
}
private suspend inline fun MapView.awaitMap(): MapLibreMap = suspendCoroutine { continuation ->
getMapAsync { map ->
continuation.resume(map)
}
}
private suspend inline fun MapLibreMap.awaitStyle(
context: Context,
styleUri: String,
images: ImmutableMap<String, Int>,
): Style = suspendCoroutine { continuation ->
setStyle(
Style.Builder().apply {
fromUri(styleUri)
images.forEach { (id, drawableRes) ->
withImage(id, checkNotNull(context.getDrawable(drawableRes)) {
"Drawable resource $drawableRes with id $id not found"
})
}
}
) { style ->
continuation.resume(style)
}
}
/**
* Registers lifecycle observers to the local [MapView].
*/
@Composable
private fun MapLifecycle(mapView: MapView) {
val context = LocalContext.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) }
DisposableEffect(context, lifecycle, mapView) {
val mapLifecycleObserver = mapView.lifecycleObserver(previousState)
val callbacks = mapView.componentCallbacks()
lifecycle.addObserver(mapLifecycleObserver)
context.registerComponentCallbacks(callbacks)
onDispose {
lifecycle.removeObserver(mapLifecycleObserver)
context.unregisterComponentCallbacks(callbacks)
}
}
DisposableEffect(mapView) {
onDispose {
mapView.onDestroy()
mapView.removeAllViews()
}
}
}
private fun MapView.lifecycleObserver(previousState: MutableState<Lifecycle.Event>): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
event.targetState
when (event) {
Lifecycle.Event.ON_CREATE -> {
// Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in
// this case the MapLibreMap composable also doesn't leave the composition. So,
// recreating the map does not restore state properly which must be avoided.
if (previousState.value != Lifecycle.Event.ON_STOP) {
this.onCreate(Bundle())
}
}
Lifecycle.Event.ON_START -> this.onStart()
Lifecycle.Event.ON_RESUME -> this.onResume()
Lifecycle.Event.ON_PAUSE -> this.onPause()
Lifecycle.Event.ON_STOP -> this.onStop()
Lifecycle.Event.ON_DESTROY -> {
// handled in onDispose
}
Lifecycle.Event.ON_ANY -> error("ON_ANY should never be used")
}
previousState.value = event
}
private fun MapView.componentCallbacks(): ComponentCallbacks2 =
object : ComponentCallbacks2 {
override fun onConfigurationChanged(config: Configuration) = Unit
@Suppress("OVERRIDE_DEPRECATION")
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
// We call the `MapView.onLowMemory` method for any memory trim level
this@componentCallbacks.onLowMemory()
}
}

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.ComposableTargetMarker
/**
* An annotation that can be used to mark a composable function as being expected to be use in a
* composable function that is also marked or inferred to be marked as a [MapLibreMapComposable].
*
* This will produce build warnings when [MapLibreMapComposable] composable functions are used outside
* of a [MapLibreMapComposable] content lambda, and vice versa.
*/
@Retention(AnnotationRetention.BINARY)
@ComposableTargetMarker(description = "MapLibre Map Composable")
@Target(
AnnotationTarget.FILE,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.TYPE,
AnnotationTarget.TYPE_PARAMETER,
)
public annotation class MapLibreMapComposable

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.ui.graphics.Color
internal val DefaultMapLocationSettings = MapLocationSettings()
/**
* Data class for UI-related settings on the map.
*
* Note: Should not be a data class if in need of maintaining binary compatibility
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
*/
public data class MapLocationSettings(
public val locationEnabled: Boolean = false,
public val backgroundTintColor: Color = Color.Unspecified,
public val foregroundTintColor: Color = Color.Unspecified,
public val backgroundStaleTintColor: Color = Color.Unspecified,
public val foregroundStaleTintColor: Color = Color.Unspecified,
public val accuracyColor: Color = Color.Unspecified,
public val pulseEnabled: Boolean = false,
public val pulseColor: Color = Color.Unspecified
)

View file

@ -1,22 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
internal val DefaultMapSymbolManagerSettings = MapSymbolManagerSettings()
/**
* Data class for UI-related settings on the map.
*
* Note: Should not be a data class if in need of maintaining binary compatibility
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
*/
public data class MapSymbolManagerSettings(
public val iconAllowOverlap: Boolean = false,
)

View file

@ -1,32 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import android.view.Gravity
import androidx.compose.ui.graphics.Color
internal val DefaultMapUiSettings = MapUiSettings()
/**
* Data class for UI-related settings on the map.
*
* Note: Should not be a data class if in need of maintaining binary compatibility
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
*/
public data class MapUiSettings(
public val compassEnabled: Boolean = true,
public val rotationGesturesEnabled: Boolean = true,
public val scrollGesturesEnabled: Boolean = true,
public val tiltGesturesEnabled: Boolean = true,
public val zoomGesturesEnabled: Boolean = true,
public val logoGravity: Int = Gravity.BOTTOM,
public val attributionGravity: Int = Gravity.BOTTOM,
public val attributionTintColor: Color = Color.Unspecified,
)

View file

@ -1,153 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:Suppress("MatchingDeclarationName")
package io.element.android.libraries.maplibre.compose
import android.annotation.SuppressLint
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ComposeNode
import androidx.compose.runtime.currentComposer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import org.maplibre.android.location.LocationComponentActivationOptions
import org.maplibre.android.location.LocationComponentOptions
import org.maplibre.android.location.OnCameraTrackingChangedListener
import org.maplibre.android.location.engine.LocationEngineRequest
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
private const val LOCATION_REQUEST_INTERVAL = 750L
internal class MapPropertiesNode(
val map: MapLibreMap,
style: Style,
context: Context,
cameraPositionState: CameraPositionState,
locationSettings: MapLocationSettings,
) : MapNode {
init {
map.locationComponent.activateLocationComponent(
LocationComponentActivationOptions.Builder(context, style)
.locationComponentOptions(
LocationComponentOptions.builder(context)
.backgroundTintColor(locationSettings.backgroundTintColor.toArgb())
.foregroundTintColor(locationSettings.foregroundTintColor.toArgb())
.backgroundStaleTintColor(locationSettings.backgroundStaleTintColor.toArgb())
.foregroundStaleTintColor(locationSettings.foregroundStaleTintColor.toArgb())
.accuracyColor(locationSettings.accuracyColor.toArgb())
.pulseEnabled(locationSettings.pulseEnabled)
.pulseColor(locationSettings.pulseColor.toArgb())
.build()
)
.locationEngineRequest(
LocationEngineRequest.Builder(LOCATION_REQUEST_INTERVAL)
.setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY)
.setFastestInterval(LOCATION_REQUEST_INTERVAL)
.build()
)
.build()
)
cameraPositionState.setMap(map)
}
var cameraPositionState = cameraPositionState
set(value) {
if (value == field) return
field.setMap(null)
field = value
value.setMap(map)
}
override fun onAttached() {
map.addOnCameraIdleListener {
cameraPositionState.isMoving = false
// addOnCameraIdleListener is only invoked when the camera position
// is changed via .animate(). To handle updating state when .move()
// is used, it's necessary to set the camera's position here as well
cameraPositionState.rawPosition = map.cameraPosition
// Updating user location on every camera move due to lack of a better location updates API.
cameraPositionState.location = map.locationComponent.lastKnownLocation
}
map.addOnCameraMoveCancelListener {
cameraPositionState.isMoving = false
}
map.addOnCameraMoveStartedListener {
cameraPositionState.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(it)
cameraPositionState.isMoving = true
}
map.addOnCameraMoveListener {
cameraPositionState.rawPosition = map.cameraPosition
// Updating user location on every camera move due to lack of a better location updates API.
cameraPositionState.location = map.locationComponent.lastKnownLocation
}
map.locationComponent.addOnCameraTrackingChangedListener(object : OnCameraTrackingChangedListener {
override fun onCameraTrackingDismissed() {}
override fun onCameraTrackingChanged(currentMode: Int) {
cameraPositionState.rawCameraMode = CameraMode.fromInternal(currentMode)
}
})
}
override fun onRemoved() {
cameraPositionState.setMap(null)
}
override fun onCleared() {
cameraPositionState.setMap(null)
}
}
/**
* Used to keep the primary map properties up to date. This should never leave the map composition.
*/
@SuppressLint("MissingPermission")
@Suppress("NOTHING_TO_INLINE")
@Composable
internal inline fun MapUpdater(
cameraPositionState: CameraPositionState,
locationSettings: MapLocationSettings,
uiSettings: MapUiSettings,
symbolManagerSettings: MapSymbolManagerSettings,
) {
val mapApplier = currentComposer.applier as MapApplier
val map = mapApplier.map
val style = mapApplier.style
val symbolManager = mapApplier.symbolManager
val context = LocalContext.current
ComposeNode<MapPropertiesNode, MapApplier>(
factory = {
MapPropertiesNode(
map = map,
style = style,
context = context,
cameraPositionState = cameraPositionState,
locationSettings = locationSettings,
)
},
update = {
set(locationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it }
set(uiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it }
set(uiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it }
set(uiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it }
set(uiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it }
set(uiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it }
set(uiSettings.logoGravity) { map.uiSettings.logoGravity = it }
set(uiSettings.attributionGravity) { map.uiSettings.attributionGravity = it }
set(uiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) }
set(symbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it }
update(cameraPositionState) { this.cameraPositionState = it }
}
)
}

View file

@ -1,114 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ComposeNode
import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.plugins.annotation.Symbol
import org.maplibre.android.plugins.annotation.SymbolManager
import org.maplibre.android.plugins.annotation.SymbolOptions
internal class SymbolNode(
val symbolManager: SymbolManager,
val symbol: Symbol,
) : MapNode {
override fun onRemoved() {
symbolManager.delete(symbol)
}
override fun onCleared() {
symbolManager.delete(symbol)
}
}
/**
* A state object that can be hoisted to control and observe the symbol state.
*
* @param position the initial symbol position
*/
public class SymbolState(
position: LatLng
) {
/**
* Current position of the symbol.
*/
public var position: LatLng by mutableStateOf(position)
public companion object {
/**
* The default saver implementation for [SymbolState].
*/
public val Saver: Saver<SymbolState, LatLng> = Saver(
save = { it.position },
restore = { SymbolState(it) }
)
}
}
@Composable
public fun rememberSymbolState(
position: LatLng = LatLng(0.0, 0.0)
): SymbolState = rememberSaveable(saver = SymbolState.Saver) {
SymbolState(position)
}
/**
* A composable for a symbol on the map.
*
* @param iconId an id of an image from the current [Style]
* @param state the [SymbolState] to be used to control or observe the symbol
* state such as its position and info window
* @param iconAnchor the anchor for the symbol image
*/
@Composable
@MapLibreMapComposable
public fun Symbol(
iconId: String,
state: SymbolState = rememberSymbolState(),
iconAnchor: IconAnchor? = null,
) {
val mapApplier = currentComposer.applier as MapApplier
val symbolManager = mapApplier.symbolManager
ComposeNode<SymbolNode, MapApplier>(
factory = {
SymbolNode(
symbolManager = symbolManager,
symbol = symbolManager.create(
SymbolOptions().apply {
withLatLng(state.position)
withIconImage(iconId)
iconAnchor?.let { withIconAnchor(it.toInternal()) }
}
),
)
},
update = {
update(state.position) {
this.symbol.latLng = it
symbolManager.update(this.symbol)
}
update(iconId) {
this.symbol.iconImage = it
symbolManager.update(this.symbol)
}
update(iconAnchor) {
this.symbol.iconAnchor = it?.toInternal()
symbolManager.update(this.symbol)
}
}
)
}

View file

@ -20,5 +20,6 @@ sealed class QrLoginException : Exception() {
data object OtherDeviceNotSignedIn : QrLoginException()
data object CheckCodeAlreadySent : QrLoginException()
data object CheckCodeCannotBeSent : QrLoginException()
data object UnsupportedQrCodeType : QrLoginException()
data object Unknown : QrLoginException()
}

View file

@ -22,6 +22,11 @@ sealed class NotificationResolverException : Exception() {
*/
data object EventFilteredOut : NotificationResolverException()
/**
* The event was found but it has been redacted.
*/
data object EventRedacted : NotificationResolverException()
/**
* An unexpected error occurred while trying to resolve the event.
*/

View file

@ -23,6 +23,11 @@ sealed class ErrorType(message: String) : Exception(message) {
*/
class UnsupportedProtocol(message: String) : ErrorType(message)
/**
* The QR code type is not supported by the client.
*/
class UnsupportedQrCodeType(message: String) : ErrorType(message)
/**
* Secrets backup not set up properly.
*/
@ -33,13 +38,33 @@ sealed class ErrorType(message: String) : Exception(message) {
*/
class NotFound(message: String) : ErrorType(message)
/**
* The device could not be created.
*/
class UnableToCreateDevice(message: String) : ErrorType(message)
/**
* An unknown error has happened.
*/
class Unknown(message: String) : ErrorType(message)
/**
* The requested device was not returned by the homeserver.
*/
class DeviceNotFound(message: String) : ErrorType(message)
/**
* The other device is already signed in and so does not need to sign in.
*/
class OtherDeviceAlreadySignedIn(message: String) : ErrorType(message)
/**
* The sign in was cancelled.
*/
class Cancelled(message: String) : ErrorType(message)
/**
* The sign in was not completed in the required time.
*/
class Expired(message: String) : ErrorType(message)
/**
* A secure connection could not have been established between the two devices.
*/
class ConnectionInsecure(message: String) : ErrorType(message)
}

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.api.media
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
@ -16,9 +17,20 @@ data class MediaSource(
/**
* Url of the media.
*/
val url: String,
private val url: String,
/**
* This is used to hold data for encrypted media.
*/
val json: String? = null,
) : Parcelable
) : Parcelable {
/**
* A URL with invalid parts (like `#fragment`, if it's an MXC url) removed.
*/
@IgnoredOnParcel
val safeUrl = if (url.startsWith("mxc")) {
// We've seen some MXC urls in the wild having some `mxc://foo/bar#auto` fragment suffix, which is invalid
url.substringBefore("#")
} else {
url
}
}

View file

@ -56,6 +56,7 @@ sealed interface NotificationContent {
data class RtcNotification(
val senderId: UserId,
val type: RtcNotificationType,
val callIntent: CallIntent,
val expirationTimestampMillis: Long
) : MessageLike
@ -127,3 +128,8 @@ enum class RtcNotificationType {
RING,
NOTIFY
}
enum class CallIntent {
AUDIO,
VIDEO
}

View file

@ -12,7 +12,7 @@ import io.element.android.libraries.matrix.api.core.DeviceId
sealed interface AccountManagementAction {
data object Profile : AccountManagementAction
data object SessionsList : AccountManagementAction
data class SessionView(val deviceId: DeviceId) : AccountManagementAction
data class SessionEnd(val deviceId: DeviceId) : AccountManagementAction
data object DevicesList : AccountManagementAction
data class DeviceView(val deviceId: DeviceId) : AccountManagementAction
data class DeviceDelete(val deviceId: DeviceId) : AccountManagementAction
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.notification.CallIntent
/**
* Represents the consensus state of [CallIntent] among room members.
* Call members can advertise their intent to use audio or video, clients can
* use this in the UI and also to decide to start camera or not when joining.
*
* This enum distinguishes between full consensus (all members advertise and
* agree), partial consensus (only some members advertise, but those who do
* agree), and no consensus (either no one advertises or advertisers disagree).
*/
sealed interface CallIntentConsensus {
/**
* All members advertise and agree on the same [callIntent].
*/
data class Full(val callIntent: CallIntent) : CallIntentConsensus
/**
* Some members advertise and agree on the same [callIntent], but not all of them.
*/
data class Partial(
/** The call intent that the agreeing members have advertised. */
val callIntent: CallIntent,
/** The number of members who advertise and agree on the same [callIntent]. */
val agreeingCount: Int,
/** The total number of members in the call. */
val totalCount: Int,
) : CallIntentConsensus
/**
* No consensus. No one advertises or advertisers disagree.
*/
data object None : CallIntentConsensus
}

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
@ -182,4 +183,30 @@ interface JoinedRoom : BaseRoom {
* Subscribe to a [Flow] of [SendQueueUpdate] related to this room.
*/
fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate>
/**
* Subscribe to live location shares in this room.
* @return Flow of list of active live location shares.
*/
fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>>
/**
* Start sharing live location in this room.
* @param durationMillis How long to share location (in milliseconds).
* @return Result indicating success or failure.
*/
suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit>
/**
* Stop sharing live location in this room.
* @return Result indicating success or failure.
*/
suspend fun stopLiveLocationShare(): Result<Unit>
/**
* Send a live location update while a live location share is active.
* @param geoUri The geo URI (e.g., "geo:51.5074,-0.1278").
* @return Result indicating success or failure.
*/
suspend fun sendLiveLocation(geoUri: String): Result<Unit>
}

View file

@ -77,6 +77,7 @@ data class RoomInfo(
val roomVersion: String?,
val privilegedCreatorRole: Boolean,
val isLowPriority: Boolean,
val activeCallIntentConsensus: CallIntentConsensus,
) {
val aliases: List<RoomAlias>
get() = listOfNotNull(canonicalAlias) + alternativeAliases

View file

@ -10,5 +10,6 @@ package io.element.android.libraries.matrix.api.room.location
enum class AssetType {
SENDER,
PIN
PIN,
UNKNOWN
}

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