feat(Diary): implement quick create functionality for training days and enhance localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Added a new button for quick creation of training days in the DiaryView, improving user experience. - Implemented logic to find the next available training slot across groups and create a training day entry. - Enhanced localization by adding new keys for quick create messages in multiple languages, ensuring better accessibility for users. - Updated the DiaryManager to handle quick create operations and clear errors effectively.
This commit is contained in:
@@ -74,6 +74,10 @@ android {
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
|
||||
29
mobile-app/composeApp/proguard-rules.pro
vendored
Normal file
29
mobile-app/composeApp/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Release / R8 (ProGuard) – Vorbereitung (Phase 14)
|
||||
|
||||
# Kotlin Serialization: @Serializable-Klassen & generierte Serializer
|
||||
-keepattributes *Annotation*, InnerClasses, EnclosingMethod
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
-keepclassmembers class kotlinx.serialization.json.** { *; }
|
||||
-dontnote kotlinx.serialization.**
|
||||
|
||||
-keepclassmembers @kotlinx.serialization.Serializable class ** {
|
||||
*** Companion;
|
||||
<fields>;
|
||||
}
|
||||
-if class **$$serializer { public static ** INSTANCE; }
|
||||
-keepclassmembers class <1>$$serializer {
|
||||
<init>(kotlinx.serialization.encoding.CompositeEncoder,kotlinx.serialization.descriptors.SerialDescriptor,int);
|
||||
private static final ** INSTANCE;
|
||||
}
|
||||
|
||||
# Ktor Client (OkHttp, Engines)
|
||||
-keep class io.ktor.client.** { *; }
|
||||
-keep class io.ktor.http.** { *; }
|
||||
-keep class io.ktor.util.** { *; }
|
||||
-keep class io.ktor.serialization.** { *; }
|
||||
|
||||
# Koin (Module / Reflection)
|
||||
-keep class org.koin.** { *; }
|
||||
|
||||
# BuildConfig (falls R8 später aktiviert wird)
|
||||
-keep class de.tsschulz.tt_tagebuch.BuildConfig { *; }
|
||||
@@ -2,6 +2,7 @@ package de.tsschulz.tt_tagebuch.app.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -746,7 +747,11 @@ private fun NavRailSectionHeader(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
contentDescription = if (expanded) {
|
||||
tr("mobile.collapseSection", "Abschnitt einklappen") + ": $title"
|
||||
} else {
|
||||
tr("mobile.expandSection", "Abschnitt ausklappen") + ": $title"
|
||||
},
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
@@ -771,6 +776,9 @@ private fun NavRailLeafItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = label
|
||||
}
|
||||
.clickable(onClick = onClick),
|
||||
color = if (selected) {
|
||||
MaterialTheme.colors.primary.copy(alpha = 0.1f)
|
||||
@@ -1376,8 +1384,59 @@ private fun DiaryListScreen(
|
||||
) {
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val diaryState by dependencies.diaryManager.state.collectAsState()
|
||||
val languageState by dependencies.languageManager.state.collectAsState()
|
||||
val androidCtx = LocalContext.current
|
||||
val clubId = clubState.currentClubId ?: return
|
||||
var dayMenuExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var showNewDateDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var newDiaryDateStr by rememberSaveable { mutableStateOf("") }
|
||||
var newDiaryStart by rememberSaveable { mutableStateOf("") }
|
||||
var newDiaryEnd by rememberSaveable { mutableStateOf("") }
|
||||
val newDateScope = rememberCoroutineScope()
|
||||
var newDateScheduleGroups by remember { mutableStateOf<List<TrainingGroupDto>>(emptyList()) }
|
||||
var newDateScheduleLoading by remember { mutableStateOf(false) }
|
||||
var newDateScheduleLoadError by remember { mutableStateOf<String?>(null) }
|
||||
var newDateGroupMenuExpanded by remember { mutableStateOf(false) }
|
||||
var selectedNewDateGroupId by remember { mutableStateOf<Int?>(null) }
|
||||
var quickCreateBusy by remember { mutableStateOf(false) }
|
||||
|
||||
val diaryDatesNormKey = remember(diaryState.dates) {
|
||||
diaryState.dates.map { it.date.take(10).trim() }.sorted().joinToString("|")
|
||||
}
|
||||
val existingDiaryDatesNorm = remember(diaryDatesNormKey) {
|
||||
diaryDatesNormKey.split("|").filter { it.isNotBlank() }.toSet()
|
||||
}
|
||||
val newDateSlotSuggestion = remember(selectedNewDateGroupId, newDateScheduleGroups, existingDiaryDatesNorm) {
|
||||
val gid = selectedNewDateGroupId ?: return@remember null
|
||||
val g = newDateScheduleGroups.find { it.id == gid } ?: return@remember null
|
||||
nextDiarySlotFromTrainingTimes(g.trainingTimes, existingDiaryDatesNorm)
|
||||
}
|
||||
|
||||
/** Sobald eine Gruppe gewählt ist und ein Vorschlag existiert: Felder wie in der Web-App füllen (nächster freier Wochentag). */
|
||||
LaunchedEffect(selectedNewDateGroupId, newDateSlotSuggestion, showNewDateDialog) {
|
||||
if (!showNewDateDialog) return@LaunchedEffect
|
||||
val sug = newDateSlotSuggestion
|
||||
if (selectedNewDateGroupId != null && sug != null) {
|
||||
newDiaryDateStr = sug.date
|
||||
newDiaryStart = sug.trainingStart
|
||||
newDiaryEnd = sug.trainingEnd
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showNewDateDialog, clubId) {
|
||||
if (!showNewDateDialog) return@LaunchedEffect
|
||||
newDateScheduleLoadError = null
|
||||
newDateScheduleLoading = true
|
||||
newDateScheduleGroups = runCatching { dependencies.membersManager.trainingScheduleGroups(clubId) }
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { t ->
|
||||
newDateScheduleLoadError = t.message ?: t.javaClass.simpleName
|
||||
emptyList()
|
||||
},
|
||||
)
|
||||
newDateScheduleLoading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
onSelectedEntryId(null)
|
||||
@@ -1437,16 +1496,66 @@ private fun DiaryListScreen(
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val today = kotlin.runCatching { java.time.LocalDate.now().toString() }.getOrElse { "2026-01-01" }
|
||||
val defaultStart = diaryState.dates.firstOrNull()?.trainingStart?.takeIf { it.isNotBlank() } ?: "17:30:00"
|
||||
val defaultEnd = diaryState.dates.firstOrNull()?.trainingEnd?.takeIf { it.isNotBlank() } ?: "19:30:00"
|
||||
dependencies.applicationScope.launch {
|
||||
dependencies.diaryManager.createDate(clubId, today, defaultStart, defaultEnd)
|
||||
dependencies.diaryManager.loadDates(clubId)
|
||||
}
|
||||
dependencies.diaryManager.clearError()
|
||||
selectedNewDateGroupId = null
|
||||
newDateGroupMenuExpanded = false
|
||||
val tmpl = diaryState.dates.firstOrNull()
|
||||
newDiaryDateStr = kotlin.runCatching { java.time.LocalDate.now().toString() }.getOrElse { "" }
|
||||
newDiaryStart = diaryTimeForFormField(tmpl?.trainingStart).ifBlank { "17:30" }
|
||||
newDiaryEnd = diaryTimeForFormField(tmpl?.trainingEnd).ifBlank { "19:30" }
|
||||
showNewDateDialog = true
|
||||
},
|
||||
modifier = Modifier.heightIn(min = TouchMinHeight),
|
||||
) { Text(tr("mobile.new", "Neu")) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (quickCreateBusy || diaryState.isLoading) return@OutlinedButton
|
||||
newDateScope.launch {
|
||||
quickCreateBusy = true
|
||||
dependencies.diaryManager.clearError()
|
||||
val lang = languageState.currentLanguageCode
|
||||
val groups = runCatching { dependencies.membersManager.trainingScheduleGroups(clubId) }.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { t ->
|
||||
quickCreateBusy = false
|
||||
Toast.makeText(
|
||||
androidCtx,
|
||||
t.message?.takeIf { it.isNotBlank() }
|
||||
?: MobileStrings.get(lang, "diary.quickCreateFailed", "Der Trainingstag konnte nicht angelegt werden."),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
return@launch
|
||||
},
|
||||
)
|
||||
val slot = findNextQuickDiarySlotAcrossGroups(groups, existingDiaryDatesNorm)
|
||||
if (slot == null) {
|
||||
quickCreateBusy = false
|
||||
Toast.makeText(
|
||||
androidCtx,
|
||||
MobileStrings.get(lang, "diary.quickCreateNoSlot", "Kein freier Trainingstermin gefunden."),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
return@launch
|
||||
}
|
||||
val id = dependencies.diaryManager.createDate(
|
||||
clubId,
|
||||
slot.date,
|
||||
diaryTimeFieldToApi(slot.trainingStart),
|
||||
diaryTimeFieldToApi(slot.trainingEnd),
|
||||
)
|
||||
quickCreateBusy = false
|
||||
if (id != null) {
|
||||
onSelectedEntryId(id)
|
||||
} else {
|
||||
val err = dependencies.diaryManager.state.value.error
|
||||
?: MobileStrings.get(lang, "diary.quickCreateFailed", "Der Trainingstag konnte nicht angelegt werden.")
|
||||
Toast.makeText(androidCtx, err, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !quickCreateBusy && !diaryState.isLoading,
|
||||
modifier = Modifier.heightIn(min = TouchMinHeight),
|
||||
) { Text(tr("diary.quickCreate", "Schnellanlegen")) }
|
||||
}
|
||||
if (diaryState.isLoading) LoadingInline()
|
||||
ErrorText(diaryState.error)
|
||||
@@ -1455,6 +1564,175 @@ private fun DiaryListScreen(
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
if (showNewDateDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
if (!diaryState.isLoading) {
|
||||
showNewDateDialog = false
|
||||
selectedNewDateGroupId = null
|
||||
newDateGroupMenuExpanded = false
|
||||
}
|
||||
},
|
||||
title = { Text(tr("diary.createNewDate", "Neuen Trainingstag anlegen")) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
tr("diary.selectTrainingGroup", "Trainingsgruppe"),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
if (newDateScheduleLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.padding(vertical = 4.dp),
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
}
|
||||
newDateScheduleLoadError?.let { err ->
|
||||
Text(
|
||||
err,
|
||||
color = MaterialTheme.colors.error,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { newDateGroupMenuExpanded = true },
|
||||
enabled = !diaryState.isLoading && !newDateScheduleLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val label = selectedNewDateGroupId?.let { gid ->
|
||||
newDateScheduleGroups.find { it.id == gid }?.name?.takeIf { it.isNotBlank() }
|
||||
} ?: tr("diary.selectTrainingGroupPlaceholder", "Gruppe wählen (optional)")
|
||||
Text(label, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = newDateGroupMenuExpanded,
|
||||
onDismissRequest = { newDateGroupMenuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
selectedNewDateGroupId = null
|
||||
newDateGroupMenuExpanded = false
|
||||
},
|
||||
) {
|
||||
Text(tr("diary.noTrainingGroupManual", "Keine / manuell"))
|
||||
}
|
||||
newDateScheduleGroups.forEach { g ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
selectedNewDateGroupId = g.id
|
||||
newDateGroupMenuExpanded = false
|
||||
},
|
||||
) {
|
||||
Text(g.name.ifBlank { "Gruppe ${g.id}" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newDateSlotSuggestion?.let { sug ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
tr("diary.suggestion", "Vorschlag"),
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"${tr("diary.nextAppointment", "Nächster Termin")}: ${formatDate(sug.date)} ${sug.trainingStart} – ${sug.trainingEnd}",
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
newDiaryDateStr = sug.date
|
||||
newDiaryStart = sug.trainingStart
|
||||
newDiaryEnd = sug.trainingEnd
|
||||
},
|
||||
enabled = !diaryState.isLoading,
|
||||
) { Text(tr("diary.applySuggestion", "Vorschlag übernehmen")) }
|
||||
}
|
||||
Divider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
OutlinedTextField(
|
||||
value = newDiaryDateStr,
|
||||
onValueChange = { newDiaryDateStr = it },
|
||||
label = { Text(tr("diary.date", "Datum (YYYY-MM-DD)")) },
|
||||
singleLine = true,
|
||||
enabled = !diaryState.isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
newDiaryDateStr = kotlin.runCatching { java.time.LocalDate.now().toString() }.getOrElse { newDiaryDateStr }
|
||||
},
|
||||
enabled = !diaryState.isLoading,
|
||||
) { Text(tr("diary.today", "Heute")) }
|
||||
OutlinedTextField(
|
||||
value = newDiaryStart,
|
||||
onValueChange = { newDiaryStart = it },
|
||||
label = { Text(tr("diary.trainingStart", "Trainingsbeginn (HH:mm)")) },
|
||||
singleLine = true,
|
||||
enabled = !diaryState.isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = newDiaryEnd,
|
||||
onValueChange = { newDiaryEnd = it },
|
||||
label = { Text(tr("diary.trainingEnd", "Trainingsende (HH:mm)")) },
|
||||
singleLine = true,
|
||||
enabled = !diaryState.isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
diaryState.error?.let { err ->
|
||||
Text(
|
||||
err,
|
||||
color = MaterialTheme.colors.error,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
newDateScope.launch {
|
||||
val id = dependencies.diaryManager.createDate(
|
||||
clubId,
|
||||
newDiaryDateStr.trim(),
|
||||
diaryTimeFieldToApi(newDiaryStart),
|
||||
diaryTimeFieldToApi(newDiaryEnd),
|
||||
)
|
||||
if (id != null) {
|
||||
showNewDateDialog = false
|
||||
selectedNewDateGroupId = null
|
||||
newDateGroupMenuExpanded = false
|
||||
onSelectedEntryId(id)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = newDiaryDateStr.trim().length >= 8 && !diaryState.isLoading,
|
||||
) { Text(tr("diary.createDate", "Anlegen")) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showNewDateDialog = false
|
||||
selectedNewDateGroupId = null
|
||||
newDateGroupMenuExpanded = false
|
||||
},
|
||||
enabled = !diaryState.isLoading,
|
||||
) { Text(tr("messages.cancel", "Abbrechen")) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -6260,6 +6538,95 @@ private fun formatTimeRange(start: String?, end: String?): String {
|
||||
return if (parts.isEmpty()) "Keine Zeiten" else parts.joinToString(" - ")
|
||||
}
|
||||
|
||||
/** Anzeige wie HTML time (HH:mm); API erwartet oft HH:mm:ss. */
|
||||
private fun diaryTimeForFormField(apiTime: String?): String {
|
||||
val t = apiTime?.trim().orEmpty()
|
||||
if (t.isEmpty()) return ""
|
||||
return t.take(5)
|
||||
}
|
||||
|
||||
private fun diaryTimeFieldToApi(value: String): String? {
|
||||
val t = value.trim()
|
||||
if (t.isEmpty()) return null
|
||||
return when {
|
||||
t.matches(Regex("""\d{2}:\d{2}:\d{2}""")) -> t
|
||||
t.matches(Regex("""\d{2}:\d{2}""")) -> "$t:00"
|
||||
else -> t
|
||||
}
|
||||
}
|
||||
|
||||
/** Nächster freier Trainingstag aus [TrainingTimeDto] (Wochentag 0=So … 6=Sa, wie Web/Kalender). */
|
||||
private data class NextDiarySlotSuggestion(
|
||||
val date: String,
|
||||
val trainingStart: String,
|
||||
val trainingEnd: String,
|
||||
)
|
||||
|
||||
private fun localDateToJsWeekday(d: java.time.LocalDate): Int =
|
||||
when (d.dayOfWeek) {
|
||||
java.time.DayOfWeek.SUNDAY -> 0
|
||||
java.time.DayOfWeek.MONDAY -> 1
|
||||
java.time.DayOfWeek.TUESDAY -> 2
|
||||
java.time.DayOfWeek.WEDNESDAY -> 3
|
||||
java.time.DayOfWeek.THURSDAY -> 4
|
||||
java.time.DayOfWeek.FRIDAY -> 5
|
||||
java.time.DayOfWeek.SATURDAY -> 6
|
||||
}
|
||||
|
||||
/** Nächster freier Kalendertag ab heute: erstes Datum ohne Tagebuch-Eintrag, dann erste passende Gruppe (Sortierung) mit Trainingszeit. */
|
||||
private fun findNextQuickDiarySlotAcrossGroups(
|
||||
groups: List<TrainingGroupDto>,
|
||||
existingDiaryDatesYyyyMmDd: Set<String>,
|
||||
): NextDiarySlotSuggestion? {
|
||||
val sortedGroups = groups.sortedWith(compareBy<TrainingGroupDto> { it.sortOrder }.thenBy { it.id })
|
||||
val today = java.time.LocalDate.now()
|
||||
for (offset in 0..366) {
|
||||
val check = today.plusDays(offset.toLong())
|
||||
val wd = localDateToJsWeekday(check)
|
||||
val norm = check.toString().take(10)
|
||||
if (norm in existingDiaryDatesYyyyMmDd) continue
|
||||
for (g in sortedGroups) {
|
||||
val timesForDay = g.trainingTimes
|
||||
.filter { it.weekday == wd && it.startTime.isNotBlank() }
|
||||
.sortedWith(compareBy<TrainingTimeDto> { it.startTime }.thenBy { it.id })
|
||||
val time = timesForDay.firstOrNull() ?: continue
|
||||
return NextDiarySlotSuggestion(
|
||||
date = norm,
|
||||
trainingStart = time.startTime.trim().take(5),
|
||||
trainingEnd = time.endTime.trim().take(5),
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun nextDiarySlotFromTrainingTimes(
|
||||
trainingTimes: List<TrainingTimeDto>,
|
||||
existingDiaryDatesYyyyMmDd: Set<String>,
|
||||
): NextDiarySlotSuggestion? {
|
||||
if (trainingTimes.isEmpty()) return null
|
||||
val sorted = trainingTimes.sortedWith(
|
||||
compareBy<TrainingTimeDto> { it.weekday }.thenBy { it.startTime }.thenBy { it.id },
|
||||
)
|
||||
val today = java.time.LocalDate.now()
|
||||
for (offset in 0..13) {
|
||||
val check = today.plusDays(offset.toLong())
|
||||
val wd = localDateToJsWeekday(check)
|
||||
val timesForDay = sorted.filter { it.weekday == wd }
|
||||
if (timesForDay.isEmpty()) continue
|
||||
val time = timesForDay.first()
|
||||
val norm = check.toString().take(10)
|
||||
if (norm !in existingDiaryDatesYyyyMmDd) {
|
||||
return NextDiarySlotSuggestion(
|
||||
date = norm,
|
||||
trainingStart = time.startTime.trim().take(5),
|
||||
trainingEnd = time.endTime.trim().take(5),
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun tr(key: String, fallback: String): String = MobileStrings.get(LocalLanguageCode.current, key, fallback)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user