chore: entferne nicht genutzte NennungsMaske-Komponente, extrahiere AktionsButtonLeiste in separaten Komponentenordner
This commit is contained in:
+31
-7
@@ -8,25 +8,37 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializationValidator
|
||||
import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
|
||||
|
||||
@Composable
|
||||
fun DeviceInitializationScreen(
|
||||
viewModel: DeviceInitializationViewModel
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val (roleSelectorFocus, nextButtonFocus) = remember { FocusRequester.createRefs() }
|
||||
|
||||
// Automatische Discovery starten, wenn wir auf Schritt 0 sind
|
||||
LaunchedEffect(uiState.currentStep) {
|
||||
if (uiState.currentStep == 0) viewModel.startDiscovery()
|
||||
if (uiState.currentStep == 0) {
|
||||
viewModel.startDiscovery()
|
||||
roleSelectorFocus.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
@@ -57,12 +69,24 @@ fun DeviceInitializationScreen(
|
||||
|
||||
NetworkRoleSelector(
|
||||
selectedRole = uiState.settings.networkRole,
|
||||
onRoleSelected = { viewModel.setNetworkRole(it) }
|
||||
onRoleSelected = {
|
||||
viewModel.setNetworkRole(it)
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
},
|
||||
modifier = Modifier.focusRequester(roleSelectorFocus)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.nextStep() },
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.focusRequester(nextButtonFocus)
|
||||
.onKeyEvent {
|
||||
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||
viewModel.nextStep()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
Text("Weiter")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
|
||||
|
||||
+11
-1
@@ -32,28 +32,37 @@ class DeviceInitializationViewModel(
|
||||
}
|
||||
|
||||
fun nextStep() {
|
||||
println("[DeviceInit] Übergang zu Schritt ${uiState.value.currentStep + 1}")
|
||||
_uiState.update { it.copy(currentStep = it.currentStep + 1) }
|
||||
}
|
||||
|
||||
fun previousStep() {
|
||||
println("[DeviceInit] Zurück zu Schritt ${(uiState.value.currentStep - 1).coerceAtLeast(0)}")
|
||||
_uiState.update { it.copy(currentStep = (it.currentStep - 1).coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
fun updateSettings(update: (DeviceInitializationSettings) -> DeviceInitializationSettings) {
|
||||
_uiState.update { it.copy(settings = update(it.settings)) }
|
||||
_uiState.update {
|
||||
val newSettings = update(it.settings)
|
||||
it.copy(settings = newSettings)
|
||||
}
|
||||
}
|
||||
|
||||
fun setNetworkRole(role: NetworkRole) {
|
||||
println("[DeviceInit] Netzwerk-Rolle gesetzt: $role")
|
||||
updateSettings { it.copy(networkRole = role) }
|
||||
}
|
||||
|
||||
fun addExpectedClient(name: String, role: NetworkRole) {
|
||||
println("[DeviceInit] Erwarteter Client hinzugefügt: $name ($role)")
|
||||
updateSettings {
|
||||
it.copy(expectedClients = it.expectedClients + ExpectedClient(name, role))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeExpectedClient(index: Int) {
|
||||
val client = _uiState.value.settings.expectedClients.getOrNull(index)
|
||||
println("[DeviceInit] Erwarteter Client entfernt: ${client?.name}")
|
||||
updateSettings {
|
||||
val newList = it.expectedClients.toMutableList().apply { removeAt(index) }
|
||||
it.copy(expectedClients = newList)
|
||||
@@ -61,6 +70,7 @@ class DeviceInitializationViewModel(
|
||||
}
|
||||
|
||||
fun completeInitialization() {
|
||||
println("[DeviceInit] Konfiguration abgeschlossen. Speichere Einstellungen...")
|
||||
onInitializationComplete(_uiState.value.settings)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-7
@@ -8,27 +8,41 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
|
||||
|
||||
@Composable
|
||||
fun NetworkRoleSelector(
|
||||
selectedRole: NetworkRole,
|
||||
onRoleSelected: (NetworkRole) -> Unit
|
||||
onRoleSelected: (NetworkRole) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NetworkRoleCard(
|
||||
title = "Master (Host)",
|
||||
description = "Verwaltet die zentrale Datenbank und koordiniert den Sync.",
|
||||
isSelected = selectedRole == NetworkRole.MASTER,
|
||||
onClick = { onRoleSelected(NetworkRole.MASTER) }
|
||||
onClick = { onRoleSelected(NetworkRole.MASTER) },
|
||||
modifier = Modifier.onKeyEvent {
|
||||
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||
onRoleSelected(NetworkRole.MASTER)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
)
|
||||
|
||||
NetworkRoleCard(
|
||||
title = "Client",
|
||||
description = "Verbindet sich mit einem Master-Gerät im lokalen Netzwerk.",
|
||||
isSelected = selectedRole == NetworkRole.CLIENT,
|
||||
onClick = { onRoleSelected(NetworkRole.CLIENT) }
|
||||
onClick = { onRoleSelected(NetworkRole.CLIENT) },
|
||||
modifier = Modifier.onKeyEvent {
|
||||
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||
onRoleSelected(NetworkRole.CLIENT)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,18 +52,19 @@ private fun NetworkRoleCard(
|
||||
title: String,
|
||||
description: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = onClick
|
||||
onClick = null
|
||||
)
|
||||
Column {
|
||||
Text(title, style = MaterialTheme.typography.labelLarge)
|
||||
|
||||
+144
-60
@@ -1,6 +1,8 @@
|
||||
package at.mocode.frontend.features.deviceinitialization.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
@@ -13,7 +15,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component3
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component4
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component5
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -26,8 +34,6 @@ import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.UIManager
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
|
||||
@Composable
|
||||
actual fun DeviceInitializationConfig(
|
||||
@@ -36,7 +42,11 @@ actual fun DeviceInitializationConfig(
|
||||
) {
|
||||
val settings = uiState.settings
|
||||
val focusManager = LocalFocusManager.current
|
||||
val (deviceNameFocus, sharedKeyFocus, backupPathFocus) = remember { FocusRequester.createRefs() }
|
||||
val (deviceNameFocus, sharedKeyFocus, backupPathFocus, clientNameFocus, clientRoleFocus) = remember { FocusRequester.createRefs() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
deviceNameFocus.requestFocus()
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
@@ -50,8 +60,15 @@ actual fun DeviceInitializationConfig(
|
||||
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
|
||||
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { sharedKeyFocus.requestFocus() }),
|
||||
modifier = Modifier.focusRequester(deviceNameFocus)
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||
modifier = Modifier.focusRequester(deviceNameFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
@@ -63,15 +80,31 @@ actual fun DeviceInitializationConfig(
|
||||
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
|
||||
errorText = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = {
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
backupPathFocus.requestFocus()
|
||||
} else {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = if (settings.networkRole == NetworkRole.MASTER) ImeAction.Next else ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) },
|
||||
onDone = {
|
||||
if (DeviceInitializationValidator.canContinue(settings)) {
|
||||
viewModel.completeInitialization()
|
||||
} else {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
}),
|
||||
modifier = Modifier.focusRequester(sharedKeyFocus),
|
||||
),
|
||||
modifier = Modifier.focusRequester(sharedKeyFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
} else if (DeviceInitializationValidator.canContinue(settings)) {
|
||||
viewModel.completeInitialization()
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
@@ -88,31 +121,24 @@ actual fun DeviceInitializationConfig(
|
||||
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
|
||||
label = { Text("Backup-Verzeichnis (Pfad)") },
|
||||
placeholder = { Text("/pfad/zu/den/backups") },
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) }
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
selectBackupPath(settings.backupPath) { selectedPath ->
|
||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) }
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
|
||||
val chooser = JFileChooser().apply {
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
dialogTitle = "Backup-Verzeichnis wählen"
|
||||
if (settings.backupPath.isNotEmpty()) {
|
||||
val currentDir = File(settings.backupPath)
|
||||
if (currentDir.exists()) currentDirectory = currentDir
|
||||
}
|
||||
}
|
||||
val result = chooser.showOpenDialog(null)
|
||||
if (result == JFileChooser.APPROVE_OPTION) {
|
||||
val selectedPath = chooser.selectedFile.absolutePath
|
||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||
println("[DeviceInit] Backup-Verzeichnis gewählt: $selectedPath")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[DeviceInit] [Error] Fehler beim Öffnen des Verzeichnis-Wählers: ${e.message}")
|
||||
selectBackupPath(settings.backupPath) { selectedPath ->
|
||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Outlined.FolderOpen, contentDescription = "Verzeichnis wählen")
|
||||
@@ -185,33 +211,27 @@ actual fun DeviceInitializationConfig(
|
||||
var newClientName by remember { mutableStateOf("") }
|
||||
var newClientRole by remember { mutableStateOf(NetworkRole.RICHTER) }
|
||||
var showAddClient by remember { mutableStateOf(false) }
|
||||
val addClientNameFocus = remember { FocusRequester() }
|
||||
|
||||
if (showAddClient) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LaunchedEffect(Unit) { addClientNameFocus.requestFocus() }
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newClientName,
|
||||
onValueChange = { newClientName = it },
|
||||
label = { Text("Gerätename des Clients") },
|
||||
modifier = Modifier.weight(1f).focusRequester(addClientNameFocus),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
|
||||
MsEnumDropdown(
|
||||
label = "Rolle",
|
||||
options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(),
|
||||
selectedOption = newClientRole,
|
||||
onOptionSelected = { newClientRole = it },
|
||||
modifier = Modifier.weight(0.5f)
|
||||
)
|
||||
}
|
||||
LaunchedEffect(Unit) { clientNameFocus.requestFocus() }
|
||||
ClientEntryRow(
|
||||
name = newClientName,
|
||||
onNameChange = { newClientName = it },
|
||||
role = newClientRole,
|
||||
onRoleChange = { newClientRole = it },
|
||||
focusManager = focusManager,
|
||||
clientNameFocus = clientNameFocus,
|
||||
clientRoleFocus = clientRoleFocus,
|
||||
onEnter = {
|
||||
if (newClientName.isNotBlank()) {
|
||||
viewModel.addExpectedClient(newClientName, newClientRole)
|
||||
println("[DeviceInit] Client hinzugefügt: $newClientName ($newClientRole)")
|
||||
newClientName = ""
|
||||
showAddClient = false
|
||||
}
|
||||
}
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
@@ -282,6 +302,48 @@ actual fun DeviceInitializationConfig(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClientEntryRow(
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
role: NetworkRole,
|
||||
onRoleChange: (NetworkRole) -> Unit,
|
||||
focusManager: androidx.compose.ui.focus.FocusManager,
|
||||
clientNameFocus: FocusRequester,
|
||||
clientRoleFocus: FocusRequester,
|
||||
onEnter: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Gerätename des Clients") },
|
||||
modifier = Modifier.weight(1f).focusRequester(clientNameFocus),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
|
||||
MsEnumDropdown(
|
||||
label = "Rolle",
|
||||
options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(),
|
||||
selectedOption = role,
|
||||
onOptionSelected = onRoleChange,
|
||||
modifier = Modifier.weight(0.5f).focusRequester(clientRoleFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
onEnter()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MsSettingsField(
|
||||
value: String,
|
||||
@@ -314,3 +376,25 @@ private fun MsSettingsField(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectBackupPath(currentPath: String, onPathSelected: (String) -> Unit) {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
|
||||
val chooser = JFileChooser().apply {
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
dialogTitle = "Backup-Verzeichnis wählen"
|
||||
if (currentPath.isNotEmpty()) {
|
||||
val currentDir = File(currentPath)
|
||||
if (currentDir.exists()) currentDirectory = currentDir
|
||||
}
|
||||
}
|
||||
val result = chooser.showOpenDialog(null)
|
||||
if (result == JFileChooser.APPROVE_OPTION) {
|
||||
val selectedPath = chooser.selectedFile.absolutePath
|
||||
onPathSelected(selectedPath)
|
||||
println("[DeviceInit] Backup-Verzeichnis gewählt: $selectedPath")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[DeviceInit] [Error] Fehler beim Öffnen des Verzeichnis-Wählers: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user