Add :feature:node (#3275)

This commit is contained in:
Phil Oliver 2025-10-01 19:26:41 -04:00 committed by GitHub
parent 5a6cd5acbc
commit d553cdfee6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 164 additions and 178 deletions

View file

@ -23,6 +23,9 @@ plugins {
android { namespace = "org.meshtastic.core.ui" }
dependencies {
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.strings)
implementation(libs.bundles.markdown)
}

View file

@ -6,6 +6,9 @@
<ID>ComposableParamOrder:BatteryInfo.kt$BatteryInfo</ID>
<ID>ComposableParamOrder:EditTextPreference.kt$EditTextPreference</ID>
<ID>ComposableParamOrder:MaterialBatteryInfo.kt$MaterialBatteryInfo</ID>
<ID>ComposableParamOrder:NodeChip.kt$NodeChip</ID>
<ID>ComposableParamOrder:SettingsItem.kt$SettingsItem</ID>
<ID>ComposableParamOrder:SignalInfo.kt$SignalInfo</ID>
<ID>ComposableParamOrder:SwitchPreference.kt$SwitchPreference</ID>
<ID>ContentSlotReused:AdaptiveTwoPane.kt$second</ID>
<ID>LongMethod:EditTextPreference.kt$@Composable fun EditTextPreference( title: String, value: String, enabled: Boolean, isError: Boolean, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, onValueChanged: (String) -&gt; Unit, modifier: Modifier = Modifier, summary: String? = null, maxSize: Int = 0, // max_size - 1 (in bytes) onFocusChanged: (FocusState) -&gt; Unit = {}, trailingIcon: (@Composable () -&gt; Unit)? = null, visualTransformation: VisualTransformation = VisualTransformation.None, )</ID>
@ -30,8 +33,12 @@
<ID>ModifierMissing:LoraSignalIndicator.kt$Rssi</ID>
<ID>ModifierMissing:LoraSignalIndicator.kt$Snr</ID>
<ID>ModifierMissing:LoraSignalIndicator.kt$SnrAndRssi</ID>
<ID>ModifierMissing:SettingsItem.kt$SettingsItem</ID>
<ID>ModifierMissing:SettingsItem.kt$SettingsItemDetail</ID>
<ID>ModifierMissing:SettingsItem.kt$SettingsItemSwitch</ID>
<ID>ModifierMissing:SimpleAlertDialog.kt$SimpleAlertDialog</ID>
<ID>ModifierMissing:SlidingSelector.kt$OptionLabel</ID>
<ID>ModifierNotUsedAtRoot:SignalInfo.kt$modifier = modifier</ID>
<ID>ModifierNotUsedAtRoot:TextDividerPreference.kt$modifier = modifier.fillMaxWidth().padding(all = 16.dp)</ID>
<ID>ModifierNotUsedAtRoot:TextDividerPreference.kt$modifier = modifier.fillMaxWidth().wrapContentWidth(Alignment.End)</ID>
<ID>ModifierReused:PreferenceCategory.kt$Card(modifier = modifier.padding(bottom = 8.dp)) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { ProvideTextStyle(MaterialTheme.typography.bodyLarge) { content() } } }</ID>
@ -52,5 +59,8 @@
<ID>PreviewPublic:IndoorAirQuality.kt$IAQScalePreview</ID>
<ID>PreviewPublic:LazyColumnDragAndDropDemo.kt$LazyColumnDragAndDropDemo</ID>
<ID>PreviewPublic:MaterialBatteryInfo.kt$MaterialBatteryInfoPreview</ID>
<ID>PreviewPublic:SignalInfo.kt$SignalInfoPreview</ID>
<ID>PreviewPublic:SignalInfo.kt$SignalInfoSelfPreview</ID>
<ID>PreviewPublic:SignalInfo.kt$SignalInfoSimplePreview</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import org.meshtastic.core.database.model.Node
@Composable
fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit)? = null) {
val (textColor, nodeColor) = node.colors
val colors = CardDefaults.cardColors(containerColor = Color(nodeColor), contentColor = Color(textColor))
val content: @Composable () -> Unit = {
Box(
modifier =
Modifier.width(IntrinsicSize.Min)
.defaultMinSize(minWidth = 72.dp, minHeight = 32.dp)
.padding(horizontal = 8.dp)
.semantics { contentDescription = node.user.shortName.ifEmpty { "Node" } },
contentAlignment = Alignment.Center,
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = node.user.shortName.ifEmpty { "???" },
fontSize = MaterialTheme.typography.labelLarge.fontSize,
textDecoration = TextDecoration.LineThrough.takeIf { node.isIgnored },
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
if (onClick == null) {
Card(modifier = modifier, shape = MaterialTheme.shapes.small, colors = colors) { content() }
} else {
Card(modifier = modifier, shape = MaterialTheme.shapes.small, colors = colors, onClick = { onClick(node) }) {
content()
}
}
}
@Suppress("MagicNumber")
@Preview
@Composable
private fun NodeChipPreview() {
val user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build()
val node =
Node(
num = 13444,
user = user,
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder().setTemperature(25f).setRelativeHumidity(60f).build(),
)
NodeChip(node = node)
}

View file

@ -0,0 +1,185 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.theme.AppTheme
/** A clickable settings button item. */
@Composable
fun SettingsItem(
text: String,
textColor: Color = LocalContentColor.current,
enabled: Boolean = true,
leadingIcon: ImageVector? = null,
leadingIconTint: Color = LocalContentColor.current,
trailingIcon: ImageVector? = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
trailingIconTint: Color = LocalContentColor.current,
onClick: () -> Unit,
) {
SettingsItem(
text = text,
textColor = textColor,
enabled = enabled,
leadingIcon = leadingIcon,
leadingIconTint = leadingIconTint,
trailingContent = { trailingIcon.Icon(trailingIconTint) },
onClick = onClick,
)
}
/** A clickable settings button item. */
@Composable
fun SettingsItem(
text: String,
textColor: Color = LocalContentColor.current,
enabled: Boolean = true,
leadingIcon: ImageVector? = null,
leadingIconTint: Color = LocalContentColor.current,
trailingContent: @Composable (() -> Unit),
onClick: () -> Unit,
) {
ClickableWrapper(enabled = enabled, onClick = onClick) {
Content(
leading = { leadingIcon.Icon(leadingIconTint) },
text = text,
textColor = textColor,
trailing = trailingContent,
)
}
}
/** A toggleable settings switch item. */
@Composable
fun SettingsItemSwitch(
checked: Boolean,
text: String,
textColor: Color = LocalContentColor.current,
enabled: Boolean = true,
leadingIcon: ImageVector? = null,
leadingIconTint: Color = LocalContentColor.current,
onClick: () -> Unit,
) {
ClickableWrapper(enabled = enabled, onClick = onClick) {
Content(
leading = { leadingIcon.Icon(leadingIconTint) },
text = text,
textColor = textColor,
trailing = { Switch(checked = checked, enabled = enabled, onCheckedChange = null) },
)
}
}
/** A settings detail item. */
@Composable
fun SettingsItemDetail(
text: String,
textColor: Color = LocalContentColor.current,
icon: ImageVector? = null,
iconTint: Color = LocalContentColor.current,
trailingText: String? = null,
enabled: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val content: @Composable ColumnScope.() -> Unit = {
Content(
leading = { icon.Icon(iconTint) },
text = text,
textColor = textColor,
trailing = { trailingText?.let { Text(text = it) } },
)
}
if (onClick != null) {
ClickableWrapper(enabled = enabled, onClick = onClick, content = content)
} else {
Column(content = content)
}
}
/** A clickable Card wrapper used for all clickable settings items. */
@Composable
private fun ClickableWrapper(enabled: Boolean, onClick: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
Card(
onClick = onClick,
enabled = enabled,
colors =
CardDefaults.cardColors(containerColor = Color.Transparent, disabledContainerColor = Color.Transparent),
content = content,
)
}
/** The row content to display for a settings item. */
@Composable
private fun Content(leading: @Composable () -> Unit, text: String, textColor: Color, trailing: @Composable () -> Unit) {
ListItem(
modifier = Modifier.padding(horizontal = 8.dp),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
headlineContent = { Text(text = text, color = textColor) },
leadingContent = { leading() },
trailingContent = { trailing() },
)
}
@Composable
private fun ImageVector?.Icon(tint: Color = LocalContentColor.current) =
this?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp), tint = tint) }
@Preview(showBackground = true)
@Composable
private fun SettingsItemPreview() {
AppTheme { SettingsItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = true) {} }
}
@Preview(showBackground = true)
@Composable
private fun SettingsItemDisabledPreview() {
AppTheme { SettingsItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = false) {} }
}
@Preview(showBackground = true)
@Composable
private fun SettingsItemSwitchPreview() {
AppTheme { SettingsItemSwitch(text = "Text", leadingIcon = Icons.Rounded.Android, checked = true) {} }
}
@Preview(showBackground = true)
@Composable
private fun SettingsItemDetailPreview() {
AppTheme { SettingsItemDetail(text = "Text 1", icon = Icons.Rounded.Android, trailingText = "Text2") }
}

View file

@ -0,0 +1,131 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
const val MAX_VALID_SNR = 100F
const val MAX_VALID_RSSI = 0
@Suppress("LongMethod")
@Composable
fun SignalInfo(modifier: Modifier = Modifier, node: Node, isThisNode: Boolean) {
val text =
if (isThisNode) {
stringResource(R.string.channel_air_util)
.format(node.deviceMetrics.channelUtilization, node.deviceMetrics.airUtilTx)
} else {
buildList {
val hopsString =
"%s: %s"
.format(
stringResource(R.string.hops_away),
if (node.hopsAway == -1) {
"?"
} else {
node.hopsAway.toString()
},
)
if (node.channel > 0) {
add("ch:${node.channel}")
}
if (node.hopsAway != 0) add(hopsString)
}
.joinToString(" ")
}
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (text.isNotEmpty()) {
Text(text = text, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelSmall)
}
/* We only know the Signal Quality from direct nodes aka 0 hop. */
if (node.hopsAway <= 0) {
if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
val quality = determineSignalQuality(node.snr, node.rssi)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Snr(node.snr)
Rssi(node.rssi)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = quality.imageVector,
contentDescription = stringResource(R.string.signal_quality),
tint = quality.color.invoke(),
)
Text(
text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
}
}
}
}
@Composable
@Preview(showBackground = true)
fun SignalInfoSimplePreview() {
AppTheme {
SignalInfo(
node = Node(num = 1, lastHeard = 0, channel = 0, snr = 12.5F, rssi = -42, hopsAway = 0),
isThisNode = false,
)
}
}
@PreviewLightDark
@Composable
fun SignalInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme { SignalInfo(node = node, isThisNode = false) }
}
@Composable
@PreviewLightDark
fun SignalInfoSelfPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme { SignalInfo(node = node, isThisNode = true) }
}

View file

@ -0,0 +1,158 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.deviceMetrics
import com.geeksville.mesh.environmentMetrics
import com.geeksville.mesh.paxcount
import com.geeksville.mesh.position
import com.geeksville.mesh.user
import com.google.protobuf.ByteString
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceMetrics.Companion.currentTime
import kotlin.random.Random
class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
val mickeyMouse =
Node(
num = 1955,
user =
user {
id = "mickeyMouseId"
longName = "Mickey Mouse"
shortName = "MM"
hwModel = MeshProtos.HardwareModel.TBEAM
role = ConfigProtos.Config.DeviceConfig.Role.ROUTER
},
position =
position {
latitudeI = 338125110
longitudeI = -1179189760
altitude = 138
satsInView = 4
},
lastHeard = currentTime(),
channel = 0,
snr = 12.5F,
rssi = -42,
deviceMetrics =
deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
isFavorite = true,
hopsAway = 0,
)
val minnieMouse =
mickeyMouse.copy(
num = Random.nextInt(),
user =
user {
longName = "Minnie Mouse"
shortName = "MiMo"
id = "minnieMouseId"
hwModel = MeshProtos.HardwareModel.HELTEC_V3
},
snr = 12.5F,
rssi = -42,
position = position {},
hopsAway = 1,
)
private val donaldDuck =
Node(
num = Random.nextInt(),
position =
position {
latitudeI = 338052347
longitudeI = -1179208460
altitude = 121
satsInView = 66
},
lastHeard = currentTime() - 300,
channel = 0,
snr = 12.5F,
rssi = -42,
deviceMetrics =
deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
user =
user {
id = "donaldDuckId"
longName = "Donald Duck, the Grand Duck of the Ducks"
shortName = "DoDu"
hwModel = MeshProtos.HardwareModel.HELTEC_V3
publicKey = ByteString.copyFrom(ByteArray(32) { 1 })
},
environmentMetrics =
environmentMetrics {
temperature = 28.0F
relativeHumidity = 50.0F
barometricPressure = 1013.25F
gasResistance = 0.0F
voltage = 3.7F
current = 0.0F
iaq = 100
},
paxcounter =
paxcount {
wifi = 30
ble = 39
uptime = 420
},
isFavorite = true,
hopsAway = 2,
)
private val unknown =
donaldDuck.copy(
user =
user {
id = "myId"
longName = "Meshtastic myId"
shortName = "myId"
hwModel = MeshProtos.HardwareModel.UNSET
},
environmentMetrics = environmentMetrics {},
paxcounter = paxcount {},
)
private val almostNothing = Node(num = Random.nextInt())
override val values: Sequence<Node>
get() =
sequenceOf(
mickeyMouse, // "this" node
unknown,
almostNothing,
minnieMouse,
donaldDuck,
)
}

View file

@ -0,0 +1,29 @@
<!--
~ Copyright (c) 2025 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12 7.5C12.69 7.5 13.27 7.73 13.76 8.2S14.5 9.27 14.5 10C14.5 11.05 14 11.81 13 12.28V21H11V12.28C10 11.81 9.5 11.05 9.5 10C9.5 9.27 9.76 8.67 10.24 8.2S11.31 7.5 12 7.5M16.69 5.3C17.94 6.55 18.61 8.11 18.7 10C18.7 11.8 18.03 13.38 16.69 14.72L15.5 13.5C16.5 12.59 17 11.42 17 10C17 8.67 16.5 7.5 15.5 6.5L16.69 5.3M6.09 4.08C4.5 5.67 3.7 7.64 3.7 10S4.5 14.3 6.09 15.89L4.92 17.11C3 15.08 2 12.7 2 10C2 7.3 3 4.94 4.92 2.91L6.09 4.08M19.08 2.91C21 4.94 22 7.3 22 10C22 12.8 21 15.17 19.08 17.11L17.91 15.89C19.5 14.3 20.3 12.33 20.3 10S19.5 5.67 17.91 4.08L19.08 2.91M7.31 5.3L8.5 6.5C7.5 7.42 7 8.58 7 10C7 11.33 7.5 12.5 8.5 13.5L7.31 14.72C5.97 13.38 5.3 11.8 5.3 10C5.3 8.2 5.97 6.64 7.31 5.3Z"
android:fillAlpha=".5"
/>
</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="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,800h480v-400L240,400v400ZM480,680q33,0 56.5,-23.5T560,600q0,-33 -23.5,-56.5T480,520q-33,0 -56.5,23.5T400,600q0,33 23.5,56.5T480,680ZM240,800v-400,400ZM240,880q-33,0 -56.5,-23.5T160,800v-400q0,-33 23.5,-56.5T240,320h280v-80q0,-83 58.5,-141.5T720,40q83,0 141.5,58.5T920,240h-80q0,-50 -35,-85t-85,-35q-50,0 -85,35t-35,85v80h120q33,0 56.5,23.5T800,400v400q0,33 -23.5,56.5T720,880L240,880Z" />
</vector>