feat: Implement pattern lock and fix lock icon visibility

- Implement pattern lock functionality with a custom pattern input view.
- Fix an issue where the lock icon was only visible for password-locked items.
- Add missing German translations.
This commit is contained in:
2025-11-03 16:00:14 +01:00
parent 06d2b21a11
commit 1dbbab7b47
15 changed files with 334 additions and 40 deletions

1
.idea/vcs.xml generated
View File

@@ -2,6 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/sharelist" vcs="Git" />
</component>
</project>

View File

@@ -645,6 +645,7 @@ fun AppShell(
var showSetPatternDialog by rememberSaveable { mutableStateOf(false) }
var showSetPinDialog by rememberSaveable { mutableStateOf(false) }
var showUnlockPasswordDialog by rememberSaveable { mutableStateOf(false) }
var showUnlockPinDialog by rememberSaveable { mutableStateOf(false) }
var unlockErrorMessage by rememberSaveable { mutableStateOf<String?>(null) }
var itemToUnlockId by rememberSaveable { mutableStateOf<Int?>(null) }
var itemToUnlockType by rememberSaveable { mutableStateOf<Screen?>(null) }
@@ -695,6 +696,10 @@ fun AppShell(
itemToUnlockType = type
showUnlockPasswordDialog = true
}
} else if (protectionType == 3) { // PIN protected
itemToUnlockId = id
itemToUnlockType = type
showUnlockPinDialog = true
}
}
@@ -1178,6 +1183,15 @@ fun AppShell(
itemToUnlockType = null
}
},
showUnlockPinDialog = showUnlockPinDialog,
onShowUnlockPinDialogChange = {
showUnlockPinDialog = it
if (!it) {
unlockErrorMessage = null
itemToUnlockId = null
itemToUnlockType = null
}
},
onUnlock = { password ->
val hashedPassword = PasswordHasher.hashPassword(password)
var isPasswordCorrect = false
@@ -1214,6 +1228,7 @@ fun AppShell(
itemToUnlockId = null
itemToUnlockType = null
showUnlockPasswordDialog = false
showUnlockPinDialog = false
} else {
unlockErrorMessage = context.getString(R.string.incorrect_password)
}

View File

@@ -93,32 +93,6 @@ fun ChooseLockMethodDialog(
)
Text(text = stringResource(R.string.pin), modifier = Modifier.padding(start = 8.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedMethod = LockMethod.PATTERN }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedMethod == LockMethod.PATTERN,
onClick = { selectedMethod = LockMethod.PATTERN }
)
Text(text = stringResource(R.string.pattern), modifier = Modifier.padding(start = 8.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedMethod = LockMethod.PIN }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedMethod == LockMethod.PIN,
onClick = { selectedMethod = LockMethod.PIN }
)
Text(text = stringResource(R.string.pin), modifier = Modifier.padding(start = 8.dp))
}
}
},
confirmButton = {

View File

@@ -0,0 +1,107 @@
package de.lxtools.noteshop.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
@Composable
fun PatternInput(
onPatternComplete: (List<Int>) -> Unit,
modifier: Modifier = Modifier
) {
var pattern by remember { mutableStateOf(emptyList<Int>()) }
var currentPosition by remember { mutableStateOf<Offset?>(null) }
val dotPositions = remember { mutableMapOf<Int, Offset>() }
val density = LocalDensity.current
Box(modifier = modifier) {
Canvas(modifier = Modifier
.size(300.dp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
currentPosition = offset
val dot = getDotAt(offset, dotPositions, density)
if (dot != null && !pattern.contains(dot)) {
pattern = pattern + dot
}
},
onDrag = { change, _ ->
currentPosition = change.position
val dot = getDotAt(change.position, dotPositions, density)
if (dot != null && !pattern.contains(dot)) {
pattern = pattern + dot
}
},
onDragEnd = {
onPatternComplete(pattern)
pattern = emptyList()
currentPosition = null
}
)
}) {
val dotRadius = 16.dp.toPx()
val dotColor = Color.Gray
val selectedDotColor = Color.Black
val lineColor = Color.Black
for (i in 1..9) {
val row = (i - 1) / 3
val col = (i - 1) % 3
val x = col * (size.width / 3) + (size.width / 6)
val y = row * (size.height / 3) + (size.height / 6)
val center = Offset(x, y)
dotPositions[i] = center
drawCircle(
color = if (pattern.contains(i)) selectedDotColor else dotColor,
radius = dotRadius,
center = center
)
}
if (pattern.isNotEmpty()) {
for (i in 0 until pattern.size - 1) {
val start = dotPositions[pattern[i]]
val end = dotPositions[pattern[i + 1]]
if (start != null && end != null) {
drawLine(
color = lineColor,
start = start,
end = end,
strokeWidth = 8.dp.toPx()
)
}
}
val lastDot = pattern.last()
val lastDotCenter = dotPositions[lastDot]
if (lastDotCenter != null && currentPosition != null) {
drawLine(
color = lineColor,
start = lastDotCenter,
end = currentPosition!!,
strokeWidth = 8.dp.toPx()
)
}
}
}
}
}
private fun getDotAt(offset: Offset, dotPositions: Map<Int, Offset>, density: Density): Int? {
for ((dot, position) in dotPositions) {
val distance = (offset - position).getDistance()
if (distance < with(density) { 50.dp.toPx() }) {
return dot
}
}
return null
}

View File

@@ -1,18 +1,35 @@
package de.lxtools.noteshop.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import de.lxtools.noteshop.R
@Composable
fun SetPatternDialog(onDismiss: () -> Unit, onSetPattern: (String) -> Unit) {
var pattern by remember { mutableStateOf(emptyList<Int>()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = "Set Pattern") },
text = { Text(text = "Pattern entry not implemented yet.") },
text = {
PatternInput(onPatternComplete = {
pattern = it
onSetPattern(it.joinToString(""))
})
},
confirmButton = {
TextButton(onClick = { onSetPattern("") }) {
TextButton(onClick = { onSetPattern(pattern.joinToString("")) }) {
Text(text = "OK")
}
},
@@ -24,20 +41,91 @@ fun SetPatternDialog(onDismiss: () -> Unit, onSetPattern: (String) -> Unit) {
)
}
@Composable
fun UnlockPatternDialog(onDismiss: () -> Unit, onUnlock: (String) -> Unit, errorMessage: String?) {
var pattern by remember { mutableStateOf(emptyList<Int>()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.unlock_item)) },
text = {
Column {
PatternInput(onPatternComplete = {
pattern = it
onUnlock(it.joinToString(""))
})
if (errorMessage != null) {
Text(text = errorMessage, color = MaterialTheme.colorScheme.error)
}
}
},
confirmButton = {
TextButton(onClick = { onUnlock(pattern.joinToString("")) }) {
Text(text = stringResource(R.string.unlock))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.cancel))
}
}
)
}
@Composable
fun SetPinDialog(onDismiss: () -> Unit, onSetPin: (String) -> Unit) {
var pin by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = "Set PIN") },
text = { Text(text = "PIN entry not implemented yet.") },
title = { Text(text = stringResource(R.string.set_pin)) },
text = {
OutlinedTextField(
value = pin,
onValueChange = { pin = it },
label = { Text(stringResource(R.string.enter_pin)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword)
)
},
confirmButton = {
TextButton(onClick = { onSetPin("") }) {
Text(text = "OK")
TextButton(onClick = { onSetPin(pin) }) {
Text(text = stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = "Cancel")
Text(text = stringResource(R.string.cancel))
}
}
)
}
@Composable
fun UnlockPinDialog(onDismiss: () -> Unit, onUnlock: (String) -> Unit, errorMessage: String?) {
var pin by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.unlock_item)) },
text = {
Column {
OutlinedTextField(
value = pin,
onValueChange = { pin = it },
label = { Text(stringResource(R.string.enter_pin)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
isError = errorMessage != null
)
if (errorMessage != null) {
Text(text = errorMessage, color = MaterialTheme.colorScheme.error)
}
}
},
confirmButton = {
TextButton(onClick = { onUnlock(pin) }) {
Text(text = stringResource(R.string.unlock))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.cancel))
}
}
)

View File

@@ -79,6 +79,8 @@ fun AppDialogs(
onShowSetListPasswordDialogChange: (Boolean) -> Unit,
showUnlockPasswordDialog: Boolean,
onShowUnlockPasswordDialogChange: (Boolean) -> Unit,
showUnlockPinDialog: Boolean,
onShowUnlockPinDialogChange: (Boolean) -> Unit,
onUnlock: (String) -> Unit,
unlockErrorMessage: String?,
showPasswordDialog: Boolean,
@@ -249,6 +251,16 @@ fun AppDialogs(
)
}
if (showUnlockPinDialog) {
de.lxtools.noteshop.ui.UnlockPinDialog(
onDismiss = {
onShowUnlockPinDialogChange(false)
},
onUnlock = onUnlock,
errorMessage = unlockErrorMessage
)
}
if (showPasswordDialog) {
EncryptionPasswordDialog(
onDismiss = { onShowPasswordDialogChange(false) },

View File

@@ -302,6 +302,15 @@ fun AppTopBar(
onShowChooseLockMethodDialog(LockableItemType.SHOPPING_LIST, listWithItems.shoppingList.id)
}
)
if (listWithItems.shoppingList.protectionType != 0) {
DropdownMenuItem(
text = { Text(stringResource(R.string.remove_lock)) },
onClick = {
showMenu = false
shoppingListsViewModel.setProtection(listWithItems.shoppingList.id, "")
}
)
}
}
}
}
@@ -378,6 +387,15 @@ fun AppTopBar(
onShowChooseLockMethodDialog(LockableItemType.NOTE, it.id)
}
)
if (it.protectionType != 0) {
DropdownMenuItem(
text = { Text(stringResource(R.string.remove_lock)) },
onClick = {
showMenu = false
notesViewModel.setProtectionPassword("")
}
)
}
}
}
}
@@ -456,6 +474,15 @@ fun AppTopBar(
onShowChooseLockMethodDialog(LockableItemType.RECIPE, it.id)
}
)
if (it.protectionType != 0) {
DropdownMenuItem(
text = { Text(stringResource(R.string.remove_lock)) },
onClick = {
showMenu = false
recipesViewModel.setProtectionPassword("")
}
)
}
}
}
}

View File

@@ -128,7 +128,7 @@ fun NoteCard(
Icon(Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.reorder))
}
}
if (note.protectionHash.isNotEmpty()) {
if (note.lockMethod != 0) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
}
Text(

View File

@@ -141,7 +141,28 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
}
fun setProtectionPin(pin: String) {
Log.d("NotesViewModel", "setProtectionPin called with pin: $pin")
viewModelScope.launch {
val currentNoteDetails = _noteDetails.value
if (pin.isNotBlank()) {
val hash = PasswordHasher.hashPassword(pin)
val updatedNote = currentNoteDetails.toNote().copy(
protectionHash = hash,
protectionType = 3, // 3 for PIN protection
lockMethod = 3
)
noteshopRepository.updateNote(updatedNote)
updateNoteDetails(updatedNote) // Update the UI state
} else {
// PIN is blank, so we remove protection
val updatedNote = currentNoteDetails.toNote().copy(
protectionHash = "",
protectionType = 0,
lockMethod = 0
)
noteshopRepository.updateNote(updatedNote)
updateNoteDetails(updatedNote) // Update the UI state
}
}
}
fun resetNoteDetails() {

View File

@@ -127,7 +127,7 @@ fun RecipeCard(
Icon(Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.reorder))
}
}
if (recipe.protectionHash.isNotEmpty()) {
if (recipe.lockMethod != 0) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
}
Text(

View File

@@ -139,7 +139,28 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
}
fun setProtectionPin(pin: String) {
Log.d("RecipesViewModel", "setProtectionPin called with pin: $pin")
viewModelScope.launch {
val currentRecipeDetails = _recipeDetails.value
if (pin.isNotBlank()) {
val hash = PasswordHasher.hashPassword(pin)
val updatedRecipe = currentRecipeDetails.toRecipe().copy(
protectionHash = hash,
protectionType = 3, // 3 for PIN protection
lockMethod = 3
)
noteshopRepository.updateRecipe(updatedRecipe)
updateRecipeDetails(updatedRecipe) // Update the UI state
} else {
// PIN is blank, so we remove protection
val updatedRecipe = currentRecipeDetails.toRecipe().copy(
protectionHash = "",
protectionType = 0,
lockMethod = 0
)
noteshopRepository.updateRecipe(updatedRecipe)
updateRecipeDetails(updatedRecipe) // Update the UI state
}
}
}
fun resetRecipeDetails() {

View File

@@ -127,7 +127,7 @@ fun ShoppingListCard(
Icon(Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.reorder))
}
}
if (listWithItems.shoppingList.protectionHash.isNotEmpty()) {
if (listWithItems.shoppingList.lockMethod != 0) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
}
Text(

View File

@@ -193,7 +193,28 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
}
fun setProtectionPin(pin: String) {
Log.d("ShoppingListsViewModel", "setProtectionPin called with pin: $pin")
viewModelScope.launch {
val currentListDetails = _listDetails.value
if (pin.isNotBlank()) {
val hash = PasswordHasher.hashPassword(pin)
val updatedList = currentListDetails.toShoppingList().copy(
protectionHash = hash,
protectionType = 3, // 3 for PIN protection
lockMethod = 3
)
noteshopRepository.updateShoppingList(updatedList)
updateListDetails(updatedList) // Update the UI state
} else {
// PIN is blank, so we remove protection
val updatedList = currentListDetails.toShoppingList().copy(
protectionHash = "",
protectionType = 0,
lockMethod = 0
)
noteshopRepository.updateShoppingList(updatedList)
updateListDetails(updatedList) // Update the UI state
}
}
}
suspend fun deleteList(list: ShoppingList) {

View File

@@ -328,4 +328,10 @@
<string name="remove_lock">Sperre entfernen</string>
<string name="choose_lock_method">Sperrmethode auswählen</string>
<string name="ok">OK</string>
<string name="biometric_lock">Biometrische Sperre</string>
<string name="pattern">Muster</string>
<string name="pin">PIN</string>
<string name="set_pin">PIN festlegen</string>
<string name="enter_pin">PIN eingeben</string>
<string name="set_pattern">Muster festlegen</string>
</resources>

View File

@@ -331,4 +331,7 @@
<string name="ok">OK</string>
<string name="pattern">Pattern</string>
<string name="pin">PIN</string>
<string name="set_pin">Set PIN</string>
<string name="enter_pin">Enter PIN</string>
<string name="set_pattern">Set Pattern</string>
</resources>