kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
refactor: channel qr clean (#1983)
rodzic
7dc2147169
commit
5edc2a8d57
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.navigation
|
||||
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navigation
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.sharing.ChannelScreen
|
||||
import com.geeksville.mesh.ui.radioconfig.components.ChannelConfigScreen
|
||||
import com.geeksville.mesh.ui.radioconfig.components.LoRaConfigScreen
|
||||
|
||||
/**
|
||||
* Navigation graph for for the top level ChannelScreen - [Route.Channels].
|
||||
*/
|
||||
fun NavGraphBuilder.channelsGraph(navController: NavHostController, uiViewModel: UIViewModel) {
|
||||
navigation<Graph.ChannelsGraph>(
|
||||
startDestination = Route.Channels,
|
||||
) {
|
||||
composable<Route.Channels> { backStackEntry ->
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry<Graph.ChannelsGraph>()
|
||||
}
|
||||
ChannelScreen(
|
||||
viewModel = uiViewModel,
|
||||
radioConfigViewModel = hiltViewModel(parentEntry),
|
||||
onNavigate = { route -> navController.navigate(route) }
|
||||
)
|
||||
}
|
||||
configRoutes(navController)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.configRoutes(
|
||||
navController: NavHostController,
|
||||
) {
|
||||
ConfigRoute.entries.forEach { configRoute ->
|
||||
composable(configRoute.route::class) { backStackEntry ->
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry<Graph.ChannelsGraph>()
|
||||
}
|
||||
when (configRoute) {
|
||||
ConfigRoute.CHANNELS -> ChannelConfigScreen(hiltViewModel(parentEntry))
|
||||
ConfigRoute.LORA -> LoRaConfigScreen(hiltViewModel(parentEntry))
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -39,7 +39,6 @@ import com.geeksville.mesh.ui.map.MapView
|
|||
import com.geeksville.mesh.ui.message.MessageScreen
|
||||
import com.geeksville.mesh.ui.message.QuickChatScreen
|
||||
import com.geeksville.mesh.ui.node.NodeScreen
|
||||
import com.geeksville.mesh.ui.sharing.ChannelScreen
|
||||
import com.geeksville.mesh.ui.sharing.ShareScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
@ -54,6 +53,9 @@ const val DEEP_LINK_BASE_URI = "meshtastic://meshtastic"
|
|||
|
||||
@Serializable
|
||||
sealed interface Graph : Route {
|
||||
@Serializable
|
||||
data class ChannelsGraph(val destNum: Int?)
|
||||
|
||||
@Serializable
|
||||
data class NodeDetailGraph(val destNum: Int) : Graph
|
||||
|
||||
|
@ -241,9 +243,9 @@ fun NavGraph(
|
|||
composable<Route.Map> {
|
||||
MapView(uIViewModel)
|
||||
}
|
||||
composable<Route.Channels> {
|
||||
ChannelScreen(uIViewModel)
|
||||
}
|
||||
|
||||
channelsGraph(navController, uIViewModel)
|
||||
|
||||
composable<Route.Connections>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink {
|
||||
|
|
|
@ -77,6 +77,11 @@ import com.geeksville.mesh.ui.radioconfig.components.StoreForwardConfigScreen
|
|||
import com.geeksville.mesh.ui.radioconfig.components.TelemetryConfigScreen
|
||||
import com.geeksville.mesh.ui.radioconfig.components.UserConfigScreen
|
||||
|
||||
fun getNavRouteFrom(routeName: String): Route? {
|
||||
return ConfigRoute.entries.find { it.name == routeName }?.route
|
||||
?: ModuleRoute.entries.find { it.name == routeName }?.route
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.radioConfigGraph(navController: NavHostController, uiViewModel: UIViewModel) {
|
||||
navigation<Graph.RadioConfigGraph>(
|
||||
startDestination = Route.RadioConfig(),
|
||||
|
|
|
@ -68,16 +68,12 @@ import com.geeksville.mesh.navigation.AdminRoute
|
|||
import com.geeksville.mesh.navigation.ConfigRoute
|
||||
import com.geeksville.mesh.navigation.ModuleRoute
|
||||
import com.geeksville.mesh.navigation.Route
|
||||
import com.geeksville.mesh.navigation.getNavRouteFrom
|
||||
import com.geeksville.mesh.ui.common.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.ui.radioconfig.components.EditDeviceProfileDialog
|
||||
import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog
|
||||
|
||||
private fun getNavRouteFrom(routeName: String): Route? {
|
||||
return ConfigRoute.entries.find { it.name == routeName }?.route
|
||||
?: ModuleRoute.entries.find { it.name == routeName }?.route
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun RadioConfigScreen(
|
||||
|
|
|
@ -23,14 +23,22 @@ import android.os.RemoteException
|
|||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.twotone.Check
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.ContentCopy
|
||||
|
@ -56,6 +64,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
|
@ -64,13 +73,14 @@ import androidx.compose.ui.platform.ClipEntry
|
|||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
@ -85,24 +95,21 @@ import com.geeksville.mesh.android.GeeksvilleApplication
|
|||
import com.geeksville.mesh.android.getCameraPermissions
|
||||
import com.geeksville.mesh.android.hasCameraPermission
|
||||
import com.geeksville.mesh.channelSet
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.ChannelOption
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.model.getChannelUrl
|
||||
import com.geeksville.mesh.model.qrCode
|
||||
import com.geeksville.mesh.model.toChannelSet
|
||||
import com.geeksville.mesh.navigation.ConfigRoute
|
||||
import com.geeksville.mesh.navigation.Route
|
||||
import com.geeksville.mesh.navigation.getNavRouteFrom
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
|
||||
import com.geeksville.mesh.ui.common.components.AdaptiveTwoPane
|
||||
import com.geeksville.mesh.ui.common.components.DropDownPreference
|
||||
import com.geeksville.mesh.ui.common.components.PreferenceFooter
|
||||
import com.geeksville.mesh.ui.common.components.dragContainer
|
||||
import com.geeksville.mesh.ui.common.components.dragDropItemsIndexed
|
||||
import com.geeksville.mesh.ui.common.components.rememberDragDropState
|
||||
import com.geeksville.mesh.ui.radioconfig.components.ChannelCard
|
||||
import com.geeksville.mesh.ui.radioconfig.components.ChannelSelection
|
||||
import com.geeksville.mesh.ui.radioconfig.components.EditChannelDialog
|
||||
import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -111,20 +118,44 @@ import kotlinx.coroutines.launch
|
|||
@Composable
|
||||
fun ChannelScreen(
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
onNavigate: (Route) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
|
||||
val enabled = connectionState == MeshService.ConnectionState.CONNECTED && !viewModel.isManaged
|
||||
|
||||
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||
var channelSet by remember(channels) { mutableStateOf(channels) }
|
||||
var showChannelEditor by rememberSaveable { mutableStateOf(false) }
|
||||
var showSendDialog by remember { mutableStateOf(false) }
|
||||
val modemPresetName by remember(channels) {
|
||||
mutableStateOf(Channel(loraConfig = channels.loraConfig).name)
|
||||
}
|
||||
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
var showScanDialog by remember { mutableStateOf(false) }
|
||||
val isEditing = channelSet != channels || showChannelEditor
|
||||
|
||||
/* Animate waiting for the channel configurations */
|
||||
var isWaiting by remember { mutableStateOf(false) }
|
||||
if (isWaiting) {
|
||||
PacketResponseStateDialog(
|
||||
state = radioConfigState.responseState,
|
||||
onDismiss = {
|
||||
isWaiting = false
|
||||
radioConfigViewModel.clearPacketResponse()
|
||||
},
|
||||
onComplete = {
|
||||
getNavRouteFrom(radioConfigState.route)?.let { route ->
|
||||
isWaiting = false
|
||||
radioConfigViewModel.clearPacketResponse()
|
||||
onNavigate(route)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/* Holds selections made by the user for QR generation. */
|
||||
val channelSelections = rememberSaveable(
|
||||
|
@ -139,7 +170,6 @@ fun ChannelScreen(
|
|||
settings.clear()
|
||||
settings.addAll(result)
|
||||
}
|
||||
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
|
||||
|
||||
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
|
@ -147,19 +177,6 @@ fun ChannelScreen(
|
|||
}
|
||||
}
|
||||
|
||||
fun updateSettingsList(update: MutableList<ChannelProtos.ChannelSettings>.() -> Unit) {
|
||||
try {
|
||||
val list = channelSet.settingsList.toMutableList()
|
||||
list.update()
|
||||
channelSet = channelSet.copy {
|
||||
settings.clear()
|
||||
settings.addAll(list)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
errormsg("Error updating ChannelSettings list:", ex)
|
||||
}
|
||||
}
|
||||
|
||||
fun zxingScan() {
|
||||
debug("Starting zxing QR code scanner")
|
||||
val zxingScan = ScanOptions()
|
||||
|
@ -209,8 +226,6 @@ fun ChannelScreen(
|
|||
|
||||
// Tell the user to try again
|
||||
viewModel.showSnackbar(R.string.cant_change_no_radio)
|
||||
} finally {
|
||||
showChannelEditor = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -255,69 +270,21 @@ fun ChannelScreen(
|
|||
)
|
||||
}
|
||||
|
||||
if (showSendDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showSendDialog = false
|
||||
showChannelEditor = false
|
||||
channelSet = channels
|
||||
},
|
||||
title = { Text(text = stringResource(id = R.string.change_channel)) },
|
||||
text = { Text(text = stringResource(id = R.string.are_you_sure_channel)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
installSettings(channelSet)
|
||||
showSendDialog = false
|
||||
}) { Text(text = stringResource(id = R.string.accept)) }
|
||||
installSettings(channelSet)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
|
||||
|
||||
if (showEditChannelDialog != null) {
|
||||
val index = showEditChannelDialog ?: return
|
||||
EditChannelDialog(
|
||||
channelSettings = with(channelSet) {
|
||||
if (settingsCount > index) getSettings(index) else channelSettings { }
|
||||
},
|
||||
modemPresetName = modemPresetName,
|
||||
onAddClick = {
|
||||
with(channelSet) {
|
||||
if (settingsCount > index) {
|
||||
channelSet = copy { settings[index] = it }
|
||||
} else {
|
||||
channelSet = copy { settings.add(it) }
|
||||
}
|
||||
}
|
||||
showEditChannelDialog = null
|
||||
},
|
||||
onDismissRequest = { showEditChannelDialog = null }
|
||||
)
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState = rememberDragDropState(listState) { fromIndex, toIndex ->
|
||||
updateSettingsList { add(toIndex, removeAt(fromIndex)) }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.dragContainer(
|
||||
dragDropState = dragDropState,
|
||||
haptics = LocalHapticFeedback.current,
|
||||
),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
if (!showChannelEditor) {
|
||||
item {
|
||||
ChannelListView(
|
||||
enabled = enabled,
|
||||
channelSet = channelSet,
|
||||
modemPresetName = modemPresetName,
|
||||
channelSelections = channelSelections,
|
||||
onClick = { showChannelEditor = true }
|
||||
onClick = {
|
||||
isWaiting = true
|
||||
radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
|
||||
}
|
||||
)
|
||||
EditChannelUrl(
|
||||
enabled = enabled,
|
||||
|
@ -325,62 +292,16 @@ fun ChannelScreen(
|
|||
onConfirm = viewModel::requestChannelUrl
|
||||
)
|
||||
}
|
||||
} else {
|
||||
dragDropItemsIndexed(
|
||||
items = channelSet.settingsList,
|
||||
dragDropState = dragDropState,
|
||||
) { index, channel, isDragging ->
|
||||
ChannelCard(
|
||||
index = index,
|
||||
title = channel.name.ifEmpty { modemPresetName },
|
||||
enabled = enabled,
|
||||
onEditClick = { showEditChannelDialog = index },
|
||||
onDeleteClick = { updateSettingsList { removeAt(index) } }
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
ModemPresetInfo(
|
||||
modemPresetName = modemPresetName,
|
||||
onClick = {
|
||||
channelSet = channelSet.copy {
|
||||
settings.add(channelSettings { psk = Channel.default.settings.psk })
|
||||
}
|
||||
showEditChannelDialog = channelSet.settingsList.lastIndex
|
||||
},
|
||||
enabled = enabled && viewModel.maxChannels > channelSet.settingsCount,
|
||||
) { Text(text = stringResource(R.string.add)) }
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
DropDownPreference(
|
||||
title = stringResource(id = R.string.channel_options),
|
||||
enabled = enabled,
|
||||
items = ChannelOption.entries
|
||||
.map { it.modemPreset to stringResource(it.configRes) },
|
||||
selectedItem = channelSet.loraConfig.modemPreset,
|
||||
onItemSelected = {
|
||||
val lora = channelSet.loraConfig.copy { modemPreset = it }
|
||||
channelSet = channelSet.copy { loraConfig = lora }
|
||||
isWaiting = true
|
||||
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
if (isEditing) {
|
||||
PreferenceFooter(
|
||||
enabled = enabled,
|
||||
onCancelClicked = {
|
||||
focusManager.clearFocus()
|
||||
showChannelEditor = false
|
||||
channelSet = channels
|
||||
},
|
||||
onSaveClicked = {
|
||||
focusManager.clearFocus()
|
||||
showSendDialog = true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
PreferenceFooter(
|
||||
enabled = enabled,
|
||||
negativeText = R.string.reset,
|
||||
|
@ -397,7 +318,6 @@ fun ChannelScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
|
@ -433,6 +353,7 @@ private fun EditChannelUrl(
|
|||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.url)) },
|
||||
isError = isError,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
trailingIcon = {
|
||||
val label = stringResource(R.string.url)
|
||||
val isUrlEqual = valueState == channelUrl
|
||||
|
@ -560,6 +481,55 @@ private fun ChannelListView(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModemPresetInfo(
|
||||
modemPresetName: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.onBackground,
|
||||
RoundedCornerShape(8.dp)
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.modem_preset),
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
Text(
|
||||
text = modemPresetName,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = stringResource(R.string.navigate_into_label),
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ModemPresetInfoPreview() {
|
||||
ModemPresetInfo(
|
||||
modemPresetName = "Long Fast",
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewScreenSizes
|
||||
@Composable
|
||||
private fun ChannelScreenPreview() {
|
||||
|
|
|
@ -481,7 +481,7 @@
|
|||
<string name="use_i2s_as_buzzer">Use I2S as buzzer</string>
|
||||
<string name="lora_config">LoRa Config</string>
|
||||
<string name="use_modem_preset">Use modem preset</string>
|
||||
<string name="modem_preset">Modem preset</string>
|
||||
<string name="modem_preset">Modem Preset</string>
|
||||
<string name="bandwidth">Bandwidth</string>
|
||||
<string name="spread_factor">Spread factor</string>
|
||||
<string name="coding_rate">Coding rate</string>
|
||||
|
@ -655,6 +655,7 @@
|
|||
<string name="disk_free">Disk Free</string>
|
||||
<string name="load">Load</string>
|
||||
<string name="user_string">User String</string>
|
||||
<string name="navigate_into_label">Navigate Into</string>
|
||||
<string name="connections">Connections</string>
|
||||
<string name="map">Map</string>
|
||||
<string name="contacts">Contacts</string>
|
||||
|
|
Ładowanie…
Reference in New Issue