Compare commits
10 Commits
e8a392aafc
...
2424068d59
| Author | SHA1 | Date | |
|---|---|---|---|
| 2424068d59 | |||
| 9abec4e66a | |||
| e9d5ff1cec | |||
| fdfbe339d8 | |||
| 6029da0ee5 | |||
| 1dbbab7b47 | |||
| 06d2b21a11 | |||
| de1d02f31b | |||
| f613e27c6d | |||
| 416f1e37aa |
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [Note::class, ShoppingList::class, ShoppingListItem::class, Recipe::class],
|
||||
version = 8, // Incremented version
|
||||
version = 10, // Incremented version
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@@ -32,7 +32,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"noteshop_database"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
// return instance
|
||||
@@ -82,5 +82,24 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
db.execSQL("ALTER TABLE shopping_lists ADD COLUMN encryptedItems TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE notes ADD COLUMN protectionHash TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE notes ADD COLUMN protectionType INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE recipes ADD COLUMN protectionHash TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE recipes ADD COLUMN protectionType INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE shopping_lists ADD COLUMN protectionHash TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE shopping_lists ADD COLUMN protectionType INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE notes ADD COLUMN lockMethod INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE recipes ADD COLUMN lockMethod INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE shopping_lists ADD COLUMN lockMethod INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,7 @@ data class Note(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val displayOrder: Int = 0,
|
||||
val isLocked: Boolean = false
|
||||
val protectionHash: String = "",
|
||||
val protectionType: Int = 0,
|
||||
val lockMethod: Int = 0
|
||||
)
|
||||
@@ -12,5 +12,7 @@ data class Recipe(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val displayOrder: Int = 0,
|
||||
val isLocked: Boolean = false
|
||||
val protectionHash: String = "",
|
||||
val protectionType: Int = 0,
|
||||
val lockMethod: Int = 0
|
||||
)
|
||||
@@ -11,6 +11,8 @@ data class ShoppingList(
|
||||
val id: Int = 0,
|
||||
val name: String,
|
||||
val displayOrder: Int = 0,
|
||||
val isLocked: Boolean = false,
|
||||
val protectionHash: String = "",
|
||||
val protectionType: Int = 0,
|
||||
val lockMethod: Int = 0,
|
||||
val encryptedItems: String? = null
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.lxtools.noteshop.security
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
object PasswordHasher {
|
||||
fun hashPassword(password: String): String {
|
||||
val bytes = password.toByteArray()
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val digest = md.digest(bytes)
|
||||
return digest.fold("") { str, it -> str + "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
1366
app/src/main/java/de/lxtools/noteshop/ui/AppShell.kt
Normal file
1366
app/src/main/java/de/lxtools/noteshop/ui/AppShell.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
package de.lxtools.noteshop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.RadioButton
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.lxtools.noteshop.R
|
||||
|
||||
enum class LockMethod {
|
||||
BIOMETRIC,
|
||||
|
||||
PASSWORD
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseLockMethodDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (LockMethod) -> Unit,
|
||||
canUseBiometrics: Boolean
|
||||
) {
|
||||
var selectedMethod by remember { mutableStateOf(LockMethod.PASSWORD) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.choose_lock_method)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = canUseBiometrics) { if(canUseBiometrics) selectedMethod = LockMethod.BIOMETRIC }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedMethod == LockMethod.BIOMETRIC,
|
||||
onClick = { if(canUseBiometrics) selectedMethod = LockMethod.BIOMETRIC },
|
||||
enabled = canUseBiometrics
|
||||
)
|
||||
Text(text = stringResource(R.string.biometric_lock), modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { selectedMethod = LockMethod.PASSWORD }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedMethod == LockMethod.PASSWORD,
|
||||
onClick = { selectedMethod = LockMethod.PASSWORD }
|
||||
)
|
||||
Text(text = stringResource(R.string.password), modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(selectedMethod) }) {
|
||||
Text(text = stringResource(R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -97,7 +97,7 @@ fun EncryptionPasswordDialog(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val useBiometrics = biometricAuthenticator.isBiometricAuthAvailable()
|
||||
val useBiometrics = false
|
||||
val pbeKey = keyManager.derivePbeKey(password.toCharArray())
|
||||
|
||||
val finalTask = { authorizedCipher: javax.crypto.Cipher ->
|
||||
@@ -115,7 +115,6 @@ fun EncryptionPasswordDialog(
|
||||
biometricAuthenticator.promptBiometricAuth(
|
||||
title = context.getString(R.string.set_encryption_password),
|
||||
subtitle = "",
|
||||
negativeButtonText = context.getString(R.string.cancel),
|
||||
fragmentActivity = context as FragmentActivity,
|
||||
crypto = cryptoObject,
|
||||
onSuccess = { result ->
|
||||
|
||||
@@ -61,7 +61,6 @@ fun JsonImportExportDialog(
|
||||
biometricAuthenticator.promptBiometricAuth(
|
||||
title = context.getString(R.string.authenticate_for_import_export),
|
||||
subtitle = "",
|
||||
negativeButtonText = context.getString(R.string.cancel),
|
||||
fragmentActivity = activity,
|
||||
crypto = crypto,
|
||||
onSuccess = { result ->
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.lxtools.noteshop.ui
|
||||
|
||||
enum class LockableItemType {
|
||||
SHOPPING_LIST,
|
||||
NOTE,
|
||||
RECIPE
|
||||
}
|
||||
107
app/src/main/java/de/lxtools/noteshop/ui/PatternInput.kt
Normal file
107
app/src/main/java/de/lxtools/noteshop/ui/PatternInput.kt
Normal 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
|
||||
}
|
||||
39
app/src/main/java/de/lxtools/noteshop/ui/RenameDialog.kt
Normal file
39
app/src/main/java/de/lxtools/noteshop/ui/RenameDialog.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
package de.lxtools.noteshop.ui
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
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 de.lxtools.noteshop.R
|
||||
|
||||
@Composable
|
||||
fun RenameDialog(currentTitle: String, onDismiss: () -> Unit, onSave: (String) -> Unit) {
|
||||
var text by remember { mutableStateOf(currentTitle) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.rename_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text(stringResource(R.string.new_name)) }
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { onSave(text) }) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -96,7 +96,6 @@ fun SettingsScreen(
|
||||
biometricAuthenticator.promptBiometricAuth(
|
||||
title = context.getString(R.string.confirm_to_proceed),
|
||||
subtitle = context.getString(R.string.authenticate_to_perform_action),
|
||||
negativeButtonText = context.getString(R.string.cancel),
|
||||
fragmentActivity = context as FragmentActivity,
|
||||
onSuccess = { successAction() },
|
||||
onFailed = {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.lxtools.noteshop.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.lxtools.noteshop.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StartupPasswordDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onUnlock: (String) -> Unit,
|
||||
errorMessage: String? = null
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.unlock_encryption)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(text = stringResource(R.string.enter_password_to_unlock_encryption))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
val image = if (showPassword) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
|
||||
val description = if (showPassword) "Hide password" else "Show password"
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(imageVector = image, description)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
errorMessage?.let {
|
||||
Text(text = it, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onUnlock(password) },
|
||||
enabled = password.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.unlock))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.lxtools.noteshop.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.lxtools.noteshop.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UnlockPasswordDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onUnlock: (String) -> Unit,
|
||||
errorMessage: String? = null
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.unlock_item)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(text = stringResource(R.string.enter_password_to_unlock))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
val image = if (showPassword) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
|
||||
val description = if (showPassword) "Hide password" else "Show password"
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(imageVector = image, description)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
errorMessage?.let {
|
||||
Text(text = it, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onUnlock(password) },
|
||||
enabled = password.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.unlock))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
216
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppContent.kt
Normal file
216
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppContent.kt
Normal file
@@ -0,0 +1,216 @@
|
||||
package de.lxtools.noteshop.ui.appshell
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.lxtools.noteshop.BiometricAuthenticator
|
||||
import de.lxtools.noteshop.Screen
|
||||
import de.lxtools.noteshop.getDynamicStrings
|
||||
import de.lxtools.noteshop.ui.AboutScreen
|
||||
import de.lxtools.noteshop.ui.GuidedTourScreen
|
||||
import de.lxtools.noteshop.ui.SettingsScreen
|
||||
import de.lxtools.noteshop.ui.notes.NoteDetailScreen
|
||||
import de.lxtools.noteshop.ui.notes.NotesScreen
|
||||
import de.lxtools.noteshop.ui.notes.NotesViewModel
|
||||
import de.lxtools.noteshop.ui.recipes.RecipesViewModel
|
||||
import de.lxtools.noteshop.ui.shopping.ShoppingListDetailScreen
|
||||
import de.lxtools.noteshop.ui.shopping.ShoppingListsScreen
|
||||
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
|
||||
import de.lxtools.noteshop.ui.theme.ColorTheme
|
||||
import de.lxtools.noteshop.ui.webapp.WebAppIntegrationScreen
|
||||
import de.lxtools.noteshop.ui.webapp.WebAppIntegrationViewModel
|
||||
|
||||
@Composable
|
||||
fun AppContent(
|
||||
innerPadding: PaddingValues,
|
||||
currentScreen: Screen,
|
||||
onScreenChange: (Screen) -> Unit,
|
||||
notesViewModel: NotesViewModel,
|
||||
shoppingListsViewModel: ShoppingListsViewModel,
|
||||
recipesViewModel: RecipesViewModel,
|
||||
selectedListId: Int?,
|
||||
onSetSelectedListId: (Int?) -> Unit,
|
||||
onShowListDialog: () -> Unit,
|
||||
selectedNoteId: Int?,
|
||||
onSetSelectedNoteId: (Int?) -> Unit,
|
||||
onShowNoteDialog: () -> Unit,
|
||||
selectedRecipeId: Int?,
|
||||
onSetSelectedRecipeId: (Int?) -> Unit,
|
||||
onShowRecipeDialog: () -> Unit,
|
||||
itemWasInitiallyLocked: Boolean,
|
||||
onUnlockItem: (Int, Screen, Int) -> Unit,
|
||||
shoppingListsTitle: String,
|
||||
onThemeChange: (ColorTheme) -> Unit,
|
||||
colorTheme: ColorTheme,
|
||||
isAppLockEnabled: Boolean,
|
||||
onAppLockChange: (Boolean) -> Unit,
|
||||
isEncryptionEnabled: Boolean,
|
||||
onEncryptionToggle: (Boolean) -> Unit,
|
||||
onSetEncryptionPassword: () -> Unit,
|
||||
onRemoveEncryption: () -> Unit,
|
||||
hasEncryptionPassword: Boolean,
|
||||
biometricAuthenticator: BiometricAuthenticator,
|
||||
sharedPrefs: android.content.SharedPreferences,
|
||||
onResetShoppingListsTitle: () -> Unit,
|
||||
onResetRecipesTitle: () -> Unit,
|
||||
startScreen: String,
|
||||
onStartScreenChange: (String) -> Unit,
|
||||
canUseBiometrics: Boolean,
|
||||
webAppIntegrationViewModel: WebAppIntegrationViewModel,
|
||||
onAuthenticateAndTogglePasswordVisibility: (Boolean) -> Unit
|
||||
) {
|
||||
val dynamicStrings = getDynamicStrings(shoppingListsTitle)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
when (currentScreen) {
|
||||
is Screen.ShoppingLists -> {
|
||||
ShoppingListsScreen(
|
||||
viewModel = shoppingListsViewModel,
|
||||
onListClick = { listId, protectionType ->
|
||||
if (protectionType != 0) {
|
||||
onUnlockItem(listId, Screen.ShoppingListDetail, protectionType)
|
||||
} else {
|
||||
onSetSelectedListId(listId)
|
||||
onScreenChange(Screen.ShoppingListDetail)
|
||||
}
|
||||
},
|
||||
onEditList = { listWithItems ->
|
||||
shoppingListsViewModel.updateListDetails(listWithItems.shoppingList)
|
||||
onShowListDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.Notes -> {
|
||||
NotesScreen(
|
||||
viewModel = notesViewModel,
|
||||
onNoteClick = { noteId, protectionType ->
|
||||
if (protectionType != 0) {
|
||||
onUnlockItem(noteId, Screen.NoteDetail, protectionType)
|
||||
} else {
|
||||
onSetSelectedNoteId(noteId)
|
||||
onScreenChange(Screen.NoteDetail)
|
||||
}
|
||||
},
|
||||
onEditNote = { note ->
|
||||
notesViewModel.updateNoteDetails(note)
|
||||
onShowNoteDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.ShoppingListDetail -> {
|
||||
ShoppingListDetailScreen(
|
||||
listId = selectedListId,
|
||||
viewModel = shoppingListsViewModel,
|
||||
dynamicStrings = dynamicStrings,
|
||||
wasInitiallyLocked = itemWasInitiallyLocked,
|
||||
isContentDecrypted = itemWasInitiallyLocked,
|
||||
onUnlockClick = {
|
||||
val list = shoppingListsViewModel.uiState.value.shoppingLists.find { it.shoppingList.id == selectedListId }?.shoppingList
|
||||
onUnlockItem(selectedListId!!, Screen.ShoppingListDetail, list?.protectionType ?: 0)
|
||||
},
|
||||
onNavigateBack = {
|
||||
onScreenChange(Screen.ShoppingLists)
|
||||
onSetSelectedListId(null)
|
||||
}
|
||||
)
|
||||
}
|
||||
is Screen.NoteDetail -> {
|
||||
NoteDetailScreen(
|
||||
noteId = selectedNoteId,
|
||||
viewModel = notesViewModel,
|
||||
wasInitiallyLocked = itemWasInitiallyLocked,
|
||||
isContentDecrypted = itemWasInitiallyLocked,
|
||||
onUnlockClick = {
|
||||
val note = notesViewModel.uiState.value.noteList.find { it.id == selectedNoteId }
|
||||
onUnlockItem(selectedNoteId!!, Screen.NoteDetail, note?.protectionType ?: 0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.Recipes -> {
|
||||
de.lxtools.noteshop.ui.recipes.RecipesScreen(
|
||||
viewModel = recipesViewModel,
|
||||
onRecipeClick = { recipeId, protectionType ->
|
||||
if (protectionType != 0) {
|
||||
onUnlockItem(recipeId, Screen.RecipeDetail, protectionType)
|
||||
} else {
|
||||
onSetSelectedRecipeId(recipeId)
|
||||
onScreenChange(Screen.RecipeDetail)
|
||||
}
|
||||
},
|
||||
onEditRecipe = { recipe ->
|
||||
recipesViewModel.updateRecipeDetails(recipe)
|
||||
onShowRecipeDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.RecipeDetail -> {
|
||||
de.lxtools.noteshop.ui.recipes.RecipeDetailScreen(
|
||||
recipeId = selectedRecipeId,
|
||||
viewModel = recipesViewModel,
|
||||
wasInitiallyLocked = itemWasInitiallyLocked,
|
||||
isContentDecrypted = itemWasInitiallyLocked,
|
||||
onUnlockClick = {
|
||||
val recipe = recipesViewModel.uiState.value.recipeList.find { it.id == selectedRecipeId }
|
||||
onUnlockItem(selectedRecipeId!!, Screen.RecipeDetail, recipe?.protectionType ?: 0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.About -> {
|
||||
AboutScreen()
|
||||
}
|
||||
|
||||
is Screen.Settings -> {
|
||||
SettingsScreen(
|
||||
onThemeChange = onThemeChange,
|
||||
currentTheme = colorTheme,
|
||||
isAppLockEnabled = isAppLockEnabled,
|
||||
onAppLockChange = onAppLockChange,
|
||||
isEncryptionEnabled = isEncryptionEnabled,
|
||||
onEncryptionToggle = onEncryptionToggle,
|
||||
onSetEncryptionPassword = onSetEncryptionPassword,
|
||||
onRemoveEncryption = onRemoveEncryption,
|
||||
hasEncryptionPassword = hasEncryptionPassword,
|
||||
biometricAuthenticator = biometricAuthenticator,
|
||||
sharedPrefs = sharedPrefs,
|
||||
onResetShoppingListsTitle = onResetShoppingListsTitle,
|
||||
onResetRecipesTitle = onResetRecipesTitle,
|
||||
currentStartScreen = startScreen,
|
||||
onStartScreenChange = onStartScreenChange,
|
||||
canUseBiometrics = canUseBiometrics,
|
||||
onNavigate = { screen -> onScreenChange(screen) }
|
||||
)
|
||||
}
|
||||
is Screen.WebAppIntegration -> {
|
||||
WebAppIntegrationScreen(
|
||||
viewModel = webAppIntegrationViewModel,
|
||||
onNavigateUp = { onScreenChange(Screen.Settings) },
|
||||
padding = innerPadding,
|
||||
onAuthenticateAndTogglePasswordVisibility = onAuthenticateAndTogglePasswordVisibility
|
||||
)
|
||||
}
|
||||
is Screen.GuidedTour -> {
|
||||
GuidedTourScreen(
|
||||
onTourFinished = { onScreenChange(Screen.Settings) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
302
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppDialogs.kt
Normal file
302
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppDialogs.kt
Normal file
@@ -0,0 +1,302 @@
|
||||
package de.lxtools.noteshop.ui.appshell
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.lxtools.noteshop.BiometricAuthenticator
|
||||
import de.lxtools.noteshop.R
|
||||
import de.lxtools.noteshop.getDynamicRecipeStrings
|
||||
import de.lxtools.noteshop.security.FileEncryptor
|
||||
import de.lxtools.noteshop.security.KeyManager
|
||||
import de.lxtools.noteshop.ui.EncryptionPasswordDialog
|
||||
import de.lxtools.noteshop.ui.JsonImportExportDialog
|
||||
import de.lxtools.noteshop.ui.RenameDialog
|
||||
import de.lxtools.noteshop.ui.UnlockPasswordDialog
|
||||
import de.lxtools.noteshop.ui.notes.NoteDetails
|
||||
import de.lxtools.noteshop.ui.notes.NoteInputDialog
|
||||
import de.lxtools.noteshop.ui.notes.NotesViewModel
|
||||
import de.lxtools.noteshop.ui.notes.SetPasswordDialog
|
||||
import de.lxtools.noteshop.ui.recipes.RecipeDetails
|
||||
import de.lxtools.noteshop.ui.recipes.RecipeInputDialog
|
||||
import de.lxtools.noteshop.ui.recipes.RecipesViewModel
|
||||
import de.lxtools.noteshop.ui.shopping.ShoppingListDetails
|
||||
import de.lxtools.noteshop.ui.shopping.ShoppingListInputDialog
|
||||
import de.lxtools.noteshop.ui.ChooseLockMethodDialog
|
||||
import de.lxtools.noteshop.ui.LockableItemType
|
||||
import de.lxtools.noteshop.ui.LockMethod
|
||||
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
|
||||
import de.lxtools.noteshop.ui.StartupPasswordDialog
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import de.lxtools.noteshop.findActivity
|
||||
|
||||
@Composable
|
||||
fun AppDialogs(
|
||||
notesViewModel: NotesViewModel,
|
||||
shoppingListsViewModel: ShoppingListsViewModel,
|
||||
recipesViewModel: RecipesViewModel,
|
||||
showListDialog: Boolean,
|
||||
onShowListDialogChange: (Boolean) -> Unit,
|
||||
listDetails: ShoppingListDetails,
|
||||
onListDetailsChange: (ShoppingListDetails) -> Unit,
|
||||
onSaveList: () -> Unit,
|
||||
onResetListDetails: () -> Unit,
|
||||
onSetListProtection: (String) -> Unit,
|
||||
onSetListProtectionBiometric: (Int) -> Unit,
|
||||
txtImportLauncher: ActivityResultLauncher<Array<String>>,
|
||||
showNoteDialog: Boolean,
|
||||
onShowNoteDialogChange: (Boolean) -> Unit,
|
||||
noteDetails: NoteDetails,
|
||||
onNoteDetailsChange: (NoteDetails) -> Unit,
|
||||
onSaveNote: () -> Unit,
|
||||
onResetNoteDetails: () -> Unit,
|
||||
onSetNoteProtectionBiometric: (Int) -> Unit,
|
||||
noteImportLauncher: ActivityResultLauncher<Array<String>>,
|
||||
showRecipeDialog: Boolean,
|
||||
onShowRecipeDialogChange: (Boolean) -> Unit,
|
||||
recipeDetails: RecipeDetails,
|
||||
onRecipeDetailsChange: (RecipeDetails) -> Unit,
|
||||
onSaveRecipe: () -> Unit,
|
||||
onResetRecipeDetails: () -> Unit,
|
||||
onSetRecipeProtectionBiometric: (Int) -> Unit,
|
||||
recipeImportLauncher: ActivityResultLauncher<Array<String>>,
|
||||
recipesTitle: String,
|
||||
showJsonDialog: Boolean,
|
||||
onShowJsonDialogChange: (Boolean) -> Unit,
|
||||
shoppingListsTitle: String,
|
||||
fileEncryptor: FileEncryptor,
|
||||
keyManager: KeyManager,
|
||||
biometricAuthenticator: BiometricAuthenticator,
|
||||
showSetPasswordDialog: Boolean,
|
||||
onShowSetPasswordDialogChange: (Boolean) -> Unit,
|
||||
onSetNotePassword: (String) -> Unit,
|
||||
showSetRecipePasswordDialog: Boolean,
|
||||
onShowSetRecipePasswordDialogChange: (Boolean) -> Unit,
|
||||
onSetRecipePassword: (String) -> Unit,
|
||||
showSetListPasswordDialog: Boolean,
|
||||
onShowSetListPasswordDialogChange: (Boolean) -> Unit,
|
||||
showUnlockPasswordDialog: Boolean,
|
||||
onShowUnlockPasswordDialogChange: (Boolean) -> Unit,
|
||||
onUnlock: (String) -> Unit,
|
||||
unlockErrorMessage: String?,
|
||||
showPasswordDialog: Boolean,
|
||||
onShowPasswordDialogChange: (Boolean) -> Unit,
|
||||
onHasEncryptionPasswordChange: (Boolean) -> Unit,
|
||||
sharedPrefs: android.content.SharedPreferences,
|
||||
showDeleteConfirmationDialog: Boolean,
|
||||
onShowDeleteConfirmationDialogChange: (Boolean) -> Unit,
|
||||
onDeleteSyncFolder: () -> Unit,
|
||||
showRenameDialog: Boolean,
|
||||
onShowRenameDialogChange: (Boolean) -> Unit,
|
||||
onRenameShoppingListsTitle: (String) -> Unit,
|
||||
showRecipeRenameDialog: Boolean,
|
||||
onShowRecipeRenameDialogChange: (Boolean) -> Unit,
|
||||
onRenameRecipesTitle: (String) -> Unit,
|
||||
showChooseLockMethodDialog: Boolean,
|
||||
onShowChooseLockMethodDialogChange: (Boolean) -> Unit,
|
||||
onConfirmLockMethod: (LockMethod) -> Unit,
|
||||
canUseBiometrics: Boolean,
|
||||
itemToLockType: de.lxtools.noteshop.ui.LockableItemType?,
|
||||
showStartupPasswordDialog: Boolean,
|
||||
onShowStartupPasswordDialogChange: (Boolean) -> Unit,
|
||||
onUnlockEncryption: (String) -> Unit,
|
||||
startupUnlockErrorMessage: String?
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val dynamicRecipeStrings = getDynamicRecipeStrings(recipesTitle)
|
||||
|
||||
if (showListDialog) {
|
||||
ShoppingListInputDialog(
|
||||
listDetails = listDetails,
|
||||
onValueChange = onListDetailsChange,
|
||||
onSave = {
|
||||
scope.launch {
|
||||
shoppingListsViewModel.saveList()
|
||||
onShowListDialogChange(false)
|
||||
}
|
||||
},
|
||||
onDismiss = { onShowListDialogChange(false) },
|
||||
onImportFromTxt = {
|
||||
txtImportLauncher.launch(arrayOf("text/plain"))
|
||||
},
|
||||
isNewList = listDetails.id == 0
|
||||
)
|
||||
}
|
||||
|
||||
if (showNoteDialog) {
|
||||
NoteInputDialog(
|
||||
noteDetails = noteDetails,
|
||||
onValueChange = onNoteDetailsChange,
|
||||
onSave = {
|
||||
scope.launch {
|
||||
notesViewModel.saveNote()
|
||||
onShowNoteDialogChange(false)
|
||||
}
|
||||
},
|
||||
onDismiss = { onShowNoteDialogChange(false) },
|
||||
onImport = {
|
||||
noteImportLauncher.launch(arrayOf("text/plain"))
|
||||
onShowNoteDialogChange(false)
|
||||
},
|
||||
isNewNote = noteDetails.id == 0
|
||||
)
|
||||
}
|
||||
|
||||
if (showRecipeDialog) {
|
||||
RecipeInputDialog(
|
||||
recipeDetails = recipeDetails,
|
||||
onValueChange = onRecipeDetailsChange,
|
||||
onSave = {
|
||||
Log.d("AppDialogs", "RecipeInputDialog onSave called")
|
||||
scope.launch {
|
||||
recipesViewModel.saveRecipe()
|
||||
onShowRecipeDialogChange(false)
|
||||
}
|
||||
},
|
||||
onDismiss = { onShowRecipeDialogChange(false) },
|
||||
onImport = {
|
||||
recipeImportLauncher.launch(arrayOf("text/plain", "text/markdown"))
|
||||
onShowRecipeDialogChange(false)
|
||||
},
|
||||
isNewRecipe = recipeDetails.id == 0,
|
||||
dynamicRecipeStrings = dynamicRecipeStrings
|
||||
)
|
||||
}
|
||||
|
||||
if (showJsonDialog) {
|
||||
JsonImportExportDialog(
|
||||
onDismissRequest = { onShowJsonDialogChange(false) },
|
||||
notesViewModel = notesViewModel,
|
||||
shoppingListsViewModel = shoppingListsViewModel,
|
||||
recipesViewModel = recipesViewModel,
|
||||
shoppingListsTitle = shoppingListsTitle,
|
||||
recipesTitle = recipesTitle,
|
||||
fileEncryptor = fileEncryptor,
|
||||
keyManager = keyManager,
|
||||
biometricAuthenticator = biometricAuthenticator
|
||||
)
|
||||
}
|
||||
|
||||
if (showSetPasswordDialog) {
|
||||
SetPasswordDialog(
|
||||
onDismiss = { onShowSetPasswordDialogChange(false) },
|
||||
onSetPassword = { password ->
|
||||
onSetNotePassword(password)
|
||||
onShowSetPasswordDialogChange(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSetRecipePasswordDialog) {
|
||||
SetPasswordDialog(
|
||||
onDismiss = { onShowSetRecipePasswordDialogChange(false) },
|
||||
onSetPassword = { password ->
|
||||
onSetRecipePassword(password)
|
||||
onShowSetRecipePasswordDialogChange(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSetListPasswordDialog) {
|
||||
SetPasswordDialog(
|
||||
onDismiss = { onShowSetListPasswordDialogChange(false) },
|
||||
onSetPassword = { password ->
|
||||
onSetListProtection(password)
|
||||
onShowSetListPasswordDialogChange(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showPasswordDialog) {
|
||||
EncryptionPasswordDialog(
|
||||
onDismiss = { onShowPasswordDialogChange(false) },
|
||||
onPasswordSet = {
|
||||
onHasEncryptionPasswordChange(true)
|
||||
onShowPasswordDialogChange(false)
|
||||
},
|
||||
keyManager = keyManager,
|
||||
sharedPrefs = sharedPrefs
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteConfirmationDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onShowDeleteConfirmationDialogChange(false) },
|
||||
title = { Text(stringResource(R.string.delete_folder)) },
|
||||
text = { Text(stringResource(R.string.delete_folder_confirmation)) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onDeleteSyncFolder()
|
||||
onShowDeleteConfirmationDialogChange(false)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = { onShowDeleteConfirmationDialogChange(false) }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showRenameDialog) {
|
||||
RenameDialog(
|
||||
currentTitle = shoppingListsTitle,
|
||||
onDismiss = { onShowRenameDialogChange(false) },
|
||||
onSave = { newTitle ->
|
||||
onRenameShoppingListsTitle(newTitle)
|
||||
onShowRenameDialogChange(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showRecipeRenameDialog) {
|
||||
RenameDialog(
|
||||
currentTitle = recipesTitle,
|
||||
onDismiss = { onShowRecipeRenameDialogChange(false) },
|
||||
onSave = { newTitle ->
|
||||
onRenameRecipesTitle(newTitle)
|
||||
onShowRecipeRenameDialogChange(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showUnlockPasswordDialog) {
|
||||
UnlockPasswordDialog(
|
||||
onDismiss = {
|
||||
onShowUnlockPasswordDialogChange(false)
|
||||
},
|
||||
onUnlock = onUnlock,
|
||||
errorMessage = unlockErrorMessage
|
||||
)
|
||||
}
|
||||
|
||||
if (showChooseLockMethodDialog) {
|
||||
ChooseLockMethodDialog(
|
||||
onDismiss = { onShowChooseLockMethodDialogChange(false) },
|
||||
onConfirm = { lockMethod ->
|
||||
onConfirmLockMethod(lockMethod)
|
||||
},
|
||||
canUseBiometrics = canUseBiometrics
|
||||
)
|
||||
}
|
||||
|
||||
if (showStartupPasswordDialog) {
|
||||
val context = LocalContext.current
|
||||
StartupPasswordDialog(
|
||||
onDismiss = {
|
||||
onShowStartupPasswordDialogChange(false)
|
||||
context.findActivity().finish()
|
||||
},
|
||||
onUnlock = onUnlockEncryption,
|
||||
errorMessage = startupUnlockErrorMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
288
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppDrawer.kt
Normal file
288
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppDrawer.kt
Normal file
@@ -0,0 +1,288 @@
|
||||
package de.lxtools.noteshop.ui.appshell
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import de.lxtools.noteshop.R
|
||||
import de.lxtools.noteshop.Screen
|
||||
import de.lxtools.noteshop.ui.notes.NotesViewModel
|
||||
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppDrawer(
|
||||
drawerState: DrawerState,
|
||||
currentScreen: Screen,
|
||||
onScreenChange: (Screen) -> Unit,
|
||||
shoppingListsViewModel: ShoppingListsViewModel,
|
||||
notesViewModel: NotesViewModel,
|
||||
shoppingListsTitle: String,
|
||||
recipesTitle: String,
|
||||
onShowRenameDialog: () -> Unit,
|
||||
onShowRecipeRenameDialog: () -> Unit,
|
||||
onShowJsonDialog: () -> Unit,
|
||||
syncFolderUriString: String?,
|
||||
onShowDeleteConfirmationDialog: () -> Unit,
|
||||
onLaunchSyncFolderChooser: () -> Unit,
|
||||
onSetSelectedListId: (Int?) -> Unit,
|
||||
onSetSelectedNoteId: (Int?) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val navigationItems = listOf(
|
||||
Screen.ShoppingLists,
|
||||
Screen.Recipes,
|
||||
Screen.Notes
|
||||
)
|
||||
|
||||
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars)) {
|
||||
LazyColumn {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFF6975BC))
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.noteshop_fg_logo6),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(navigationItems, key = { it.route }) { screen ->
|
||||
when (screen) {
|
||||
is Screen.ShoppingLists -> {
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(shoppingListsTitle, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { onShowRenameDialog() }) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = stringResource(R.string.rename_title)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
selected = screen == currentScreen,
|
||||
onClick = {
|
||||
shoppingListsViewModel.disableReorderMode()
|
||||
notesViewModel.disableReorderMode()
|
||||
onScreenChange(screen)
|
||||
onSetSelectedListId(null)
|
||||
onSetSelectedNoteId(null)
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.Recipes -> {
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(recipesTitle, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = {
|
||||
onShowRecipeRenameDialog()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = stringResource(R.string.rename_title)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
selected = screen == currentScreen,
|
||||
onClick = {
|
||||
shoppingListsViewModel.disableReorderMode()
|
||||
notesViewModel.disableReorderMode()
|
||||
onScreenChange(screen)
|
||||
onSetSelectedListId(null)
|
||||
onSetSelectedNoteId(null)
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = screen.titleRes)) },
|
||||
selected = screen == currentScreen,
|
||||
onClick = {
|
||||
shoppingListsViewModel.disableReorderMode()
|
||||
notesViewModel.disableReorderMode()
|
||||
onScreenChange(screen)
|
||||
onSetSelectedListId(null)
|
||||
onSetSelectedNoteId(null)
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
val standardListName = stringResource(R.string.standard_list_name)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.load_standard_list)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
shoppingListsViewModel.createStandardList(standardListName)
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
item {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.json_import_export_title)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
onShowJsonDialog()
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(id = R.string.select_sync_folder))
|
||||
val displayPath =
|
||||
if (syncFolderUriString.isNullOrBlank()) {
|
||||
stringResource(id = R.string.no_folder_selected)
|
||||
} else {
|
||||
val path = remember(syncFolderUriString) {
|
||||
try {
|
||||
val uri = syncFolderUriString.toUri()
|
||||
val docFile =
|
||||
DocumentFile.fromTreeUri(context, uri)
|
||||
if (docFile?.name == "Noteshop") {
|
||||
val parent = docFile.parentFile
|
||||
if (parent != null) {
|
||||
"${parent.name} / ${docFile.name}"
|
||||
} else {
|
||||
docFile.name
|
||||
}
|
||||
} else {
|
||||
val parentName = docFile?.name
|
||||
if (parentName != null) {
|
||||
"$parentName / Noteshop"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (path != null) {
|
||||
stringResource(id = R.string.selected_folder) + " " + path
|
||||
} else {
|
||||
stringResource(id = R.string.no_folder_selected)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = displayPath,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (!syncFolderUriString.isNullOrBlank()) {
|
||||
IconButton(onClick = {
|
||||
onShowDeleteConfirmationDialog()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.delete_folder)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
selected = false,
|
||||
onClick = {
|
||||
onLaunchSyncFolderChooser()
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
item {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.menu_settings)) },
|
||||
selected = currentScreen == Screen.Settings,
|
||||
onClick = {
|
||||
onScreenChange(Screen.Settings)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Settings,
|
||||
contentDescription = stringResource(R.string.menu_settings)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.menu_about)) },
|
||||
selected = currentScreen == Screen.About,
|
||||
onClick = {
|
||||
onScreenChange(Screen.About)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Info,
|
||||
contentDescription = stringResource(R.string.menu_about)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
556
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppTopBar.kt
Normal file
556
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppTopBar.kt
Normal file
@@ -0,0 +1,556 @@
|
||||
package de.lxtools.noteshop.ui.appshell
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import de.lxtools.noteshop.R
|
||||
import de.lxtools.noteshop.Screen
|
||||
import de.lxtools.noteshop.getDynamicRecipeStrings
|
||||
import de.lxtools.noteshop.ui.LockableItemType
|
||||
import de.lxtools.noteshop.ui.notes.NotesViewModel
|
||||
import de.lxtools.noteshop.ui.recipes.RecipesViewModel
|
||||
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppTopBar(
|
||||
currentScreen: Screen,
|
||||
onScreenChange: (Screen) -> Unit,
|
||||
drawerState: DrawerState,
|
||||
shoppingListsViewModel: ShoppingListsViewModel,
|
||||
notesViewModel: NotesViewModel,
|
||||
recipesViewModel: RecipesViewModel,
|
||||
shoppingListsTitle: String,
|
||||
recipesTitle: String,
|
||||
onShowListDialog: () -> Unit,
|
||||
onShowNoteDialog: () -> Unit,
|
||||
onShowRecipeDialog: () -> Unit,
|
||||
selectedListId: Int?,
|
||||
onSetSelectedListId: (Int?) -> Unit,
|
||||
onSetSelectedNoteId: (Int?) -> Unit,
|
||||
onSetSelectedRecipeId: (Int?) -> Unit,
|
||||
exportLauncher: ActivityResultLauncher<String>,
|
||||
noteExportLauncher: ActivityResultLauncher<String>,
|
||||
recipeExportLauncher: ActivityResultLauncher<String>,
|
||||
hasEncryptionPassword: Boolean,
|
||||
onShowSetPasswordDialog: () -> Unit,
|
||||
onShowSetRecipePasswordDialog: () -> Unit,
|
||||
onShowSetListPasswordDialog: () -> Unit,
|
||||
onShowChooseLockMethodDialog: (LockableItemType, Int) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val isReorderMode by shoppingListsViewModel.isReorderMode.collectAsState()
|
||||
val isNotesReorderMode by notesViewModel.isReorderMode.collectAsState()
|
||||
val isRecipesReorderMode by recipesViewModel.isReorderMode.collectAsState()
|
||||
val isDetailSearchActive by shoppingListsViewModel.isDetailSearchActive.collectAsState()
|
||||
val shoppingListWithItems by shoppingListsViewModel.getShoppingListWithItemsStream(
|
||||
selectedListId ?: 0
|
||||
).collectAsState(initial = null)
|
||||
val noteDetails by notesViewModel.noteDetails.collectAsState()
|
||||
val recipeDetails by recipesViewModel.recipeDetails.collectAsState()
|
||||
|
||||
val topBarTitle = when (currentScreen) {
|
||||
is Screen.ShoppingListDetail -> shoppingListWithItems?.shoppingList?.name ?: ""
|
||||
is Screen.NoteDetail -> notesViewModel.noteDetails.collectAsState().value.title
|
||||
is Screen.RecipeDetail -> recipesViewModel.recipeDetails.collectAsState().value.title
|
||||
is Screen.ShoppingLists -> shoppingListsTitle
|
||||
is Screen.Recipes -> recipesTitle
|
||||
else -> stringResource(id = currentScreen.titleRes)
|
||||
}
|
||||
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
if (currentScreen == Screen.ShoppingListDetail && isDetailSearchActive) stringResource(
|
||||
R.string.search
|
||||
) else topBarTitle
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
if (isReorderMode || isNotesReorderMode || isRecipesReorderMode) {
|
||||
IconButton(onClick = {
|
||||
if (isReorderMode) shoppingListsViewModel.disableReorderMode()
|
||||
if (isNotesReorderMode) notesViewModel.disableReorderMode()
|
||||
if (isRecipesReorderMode) recipesViewModel.disableReorderMode()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
when (currentScreen) {
|
||||
is Screen.ShoppingListDetail -> {
|
||||
IconButton(onClick = {
|
||||
if (isDetailSearchActive) {
|
||||
shoppingListsViewModel.toggleDetailSearch()
|
||||
} else {
|
||||
onScreenChange(Screen.ShoppingLists)
|
||||
onSetSelectedListId(null)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is Screen.NoteDetail -> {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
notesViewModel.saveNote()
|
||||
onScreenChange(Screen.Notes)
|
||||
onSetSelectedNoteId(null)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is Screen.RecipeDetail -> {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
recipesViewModel.saveRecipe()
|
||||
onScreenChange(Screen.Recipes)
|
||||
onSetSelectedRecipeId(null)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is Screen.WebAppIntegration -> {
|
||||
IconButton(onClick = { onScreenChange(Screen.Settings) }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
IconButton(onClick = { scope.launch { drawerState.apply { if (isClosed) open() else close() } } }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(id = R.string.menu_open)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (currentScreen == Screen.ShoppingLists && !isReorderMode) {
|
||||
IconButton(onClick = {
|
||||
shoppingListsViewModel.resetListDetails()
|
||||
onShowListDialog()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.add_shopping_list)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (currentScreen == Screen.Notes && !isNotesReorderMode) {
|
||||
IconButton(onClick = {
|
||||
notesViewModel.resetNoteDetails()
|
||||
onShowNoteDialog()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.add_note)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (currentScreen == Screen.Recipes && !isRecipesReorderMode) {
|
||||
IconButton(onClick = {
|
||||
recipesViewModel.resetRecipeDetails()
|
||||
onShowRecipeDialog()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = getDynamicRecipeStrings(recipesTitle).addItem
|
||||
)
|
||||
}
|
||||
}
|
||||
when (currentScreen) {
|
||||
is Screen.ShoppingListDetail -> {
|
||||
IconButton(onClick = {
|
||||
selectedListId?.let {
|
||||
shoppingListsViewModel.importItemsFromWebApp(
|
||||
it
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.CloudDownload,
|
||||
contentDescription = stringResource(R.string.import_from_web_app)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { shoppingListsViewModel.toggleDetailSearch() }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { showMenu = !showMenu }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "More")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.export)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
shoppingListWithItems?.let {
|
||||
val fileName = "${it.shoppingList.name}.txt"
|
||||
exportLauncher.launch(fileName)
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.share)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
shoppingListWithItems?.let { list ->
|
||||
val content =
|
||||
shoppingListsViewModel.formatShoppingListForExport(
|
||||
list
|
||||
)
|
||||
val fileName = "${list.shoppingList.name}.txt"
|
||||
val cachePath = java.io.File(
|
||||
context.cacheDir,
|
||||
"shared_files"
|
||||
)
|
||||
cachePath.mkdirs()
|
||||
val newFile = java.io.File(cachePath, fileName)
|
||||
newFile.writeText(content)
|
||||
|
||||
val contentUri: Uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
newFile
|
||||
)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, contentUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
context.getString(R.string.share)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
shoppingListWithItems?.let { listWithItems ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.lock)) },
|
||||
enabled = hasEncryptionPassword,
|
||||
onClick = {
|
||||
showMenu = false
|
||||
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, "")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Screen.NoteDetail -> {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { showMenu = !showMenu }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "More")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.export)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
notesViewModel.noteDetails.value.toNote()
|
||||
.let { note ->
|
||||
val fileName = "${note.title}.txt"
|
||||
noteExportLauncher.launch(fileName)
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.share)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
notesViewModel.noteDetails.value.toNote()
|
||||
.let { note ->
|
||||
val content =
|
||||
notesViewModel.formatNoteForExport(note)
|
||||
val fileName = "${note.title}.txt"
|
||||
val cachePath = java.io.File(
|
||||
context.cacheDir,
|
||||
"shared_files"
|
||||
)
|
||||
cachePath.mkdirs()
|
||||
val newFile =
|
||||
java.io.File(cachePath, fileName)
|
||||
newFile.writeText(content)
|
||||
|
||||
val contentUri: Uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
newFile
|
||||
)
|
||||
|
||||
val shareIntent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
contentUri
|
||||
)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
context.getString(R.string.share)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
noteDetails.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.lock)) },
|
||||
enabled = hasEncryptionPassword,
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onShowChooseLockMethodDialog(LockableItemType.NOTE, it.id)
|
||||
}
|
||||
)
|
||||
if (it.protectionType != 0) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.remove_lock)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
notesViewModel.setProtectionPassword("")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Screen.RecipeDetail -> {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { showMenu = !showMenu }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "More")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.export)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
recipesViewModel.recipeDetails.value.toRecipe()
|
||||
.let { recipe ->
|
||||
val fileName = "${recipe.title}.txt"
|
||||
recipeExportLauncher.launch(fileName)
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.share)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
recipesViewModel.recipeDetails.value.toRecipe()
|
||||
.let { recipe ->
|
||||
val content =
|
||||
recipesViewModel.formatRecipeForExport(
|
||||
recipe
|
||||
)
|
||||
val fileName = "${recipe.title}.txt"
|
||||
val cachePath = java.io.File(
|
||||
context.cacheDir,
|
||||
"shared_files"
|
||||
)
|
||||
cachePath.mkdirs()
|
||||
val newFile =
|
||||
java.io.File(cachePath, fileName)
|
||||
newFile.writeText(content)
|
||||
|
||||
val contentUri: Uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
newFile
|
||||
)
|
||||
|
||||
val shareIntent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
contentUri
|
||||
)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
context.getString(R.string.share)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
recipeDetails.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.lock)) },
|
||||
enabled = hasEncryptionPassword,
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onShowChooseLockMethodDialog(LockableItemType.RECIPE, it.id)
|
||||
}
|
||||
)
|
||||
if (it.protectionType != 0) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.remove_lock)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
recipesViewModel.setProtectionPassword("")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
when (currentScreen) {
|
||||
Screen.ShoppingLists -> {
|
||||
OutlinedTextField(
|
||||
value = shoppingListsViewModel.searchQuery.collectAsState().value,
|
||||
onValueChange = shoppingListsViewModel::updateSearchQuery,
|
||||
label = { Text(stringResource(R.string.search_list_hint)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Screen.Notes -> {
|
||||
OutlinedTextField(
|
||||
value = notesViewModel.searchQuery.collectAsState().value,
|
||||
onValueChange = notesViewModel::updateSearchQuery,
|
||||
label = { Text(stringResource(R.string.search_notes_hint)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Screen.Recipes -> {
|
||||
OutlinedTextField(
|
||||
value = recipesViewModel.searchQuery.collectAsState().value,
|
||||
onValueChange = recipesViewModel::updateSearchQuery,
|
||||
label = { Text(getDynamicRecipeStrings(recipesTitle).searchHint) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,31 +44,18 @@ fun NoteDetailScreen(
|
||||
noteId: Int?,
|
||||
viewModel: NotesViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onUnlockClick: () -> Unit,
|
||||
secretKey: SecretKey?,
|
||||
fileEncryptor: FileEncryptor
|
||||
wasInitiallyLocked: Boolean,
|
||||
isContentDecrypted: Boolean,
|
||||
onUnlockClick: (Int) -> Unit
|
||||
) {
|
||||
|
||||
val noteDetails by viewModel.noteDetails.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var wasLockedInitially by rememberSaveable { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
LaunchedEffect(noteDetails) {
|
||||
if (wasLockedInitially == null) { // This ensures we only set it once
|
||||
wasLockedInitially = noteDetails.isLocked
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(noteId) {
|
||||
onDispose {
|
||||
val activity = context.findActivity()
|
||||
if (!activity.isChangingConfigurations) {
|
||||
if (wasLockedInitially == true) {
|
||||
viewModel.toggleNoteLock(noteId ?: 0, secretKey, fileEncryptor)
|
||||
}
|
||||
}
|
||||
// The automatic re-locking logic has been removed in favor of a stateless protection model.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +68,8 @@ fun NoteDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (noteDetails.isLocked) {
|
||||
LockedScreenPlaceholder(onUnlockClick = onUnlockClick)
|
||||
if (noteDetails.protectionHash.isNotEmpty() && !isContentDecrypted) {
|
||||
LockedScreenPlaceholder(onUnlockClick = { onUnlockClick(noteDetails.protectionType) })
|
||||
} else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
|
||||
@@ -47,7 +47,7 @@ fun NotesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: NotesViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||
|
||||
onNoteClick: (Int) -> Unit,
|
||||
onNoteClick: (Int, Int) -> Unit,
|
||||
onEditNote: (Note) -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
@@ -81,7 +81,7 @@ fun NotesScreen(
|
||||
if (!isReorderMode) {
|
||||
detectTapGestures(
|
||||
onLongPress = { viewModel.enableReorderMode() },
|
||||
onTap = { onNoteClick(note.id) }
|
||||
onTap = { onNoteClick(note.id, note.protectionType) }
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -128,7 +128,7 @@ fun NoteCard(
|
||||
Icon(Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.reorder))
|
||||
}
|
||||
}
|
||||
if (note.isLocked) {
|
||||
if (note.lockMethod != 0) {
|
||||
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.util.Log
|
||||
import de.lxtools.noteshop.security.FileEncryptor
|
||||
import java.util.Base64
|
||||
import javax.crypto.SecretKey
|
||||
import de.lxtools.noteshop.security.PasswordHasher
|
||||
|
||||
class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewModel() {
|
||||
|
||||
@@ -38,48 +39,7 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun toggleNoteLock(noteId: Int, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val note = noteshopRepository.getNoteStream(noteId).firstOrNull()
|
||||
note?.let {
|
||||
val newLockedState = !it.isLocked
|
||||
var noteToUpdate = it.copy(isLocked = newLockedState)
|
||||
if (newLockedState) {
|
||||
// Encrypt
|
||||
try {
|
||||
val encryptedContent = Base64.getEncoder().encodeToString(
|
||||
fileEncryptor.encrypt(it.content.toByteArray(Charsets.UTF_8), secretKey!!)
|
||||
)
|
||||
noteToUpdate = noteToUpdate.copy(
|
||||
content = encryptedContent
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("NotesViewModel", "Failed to encrypt note content on lock.", e)
|
||||
return@launch // Don't update if encryption fails
|
||||
}
|
||||
} else {
|
||||
// Decrypt
|
||||
var decryptedContent: String
|
||||
try {
|
||||
decryptedContent = String(fileEncryptor.decrypt(Base64.getDecoder().decode(it.content), secretKey!!), Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
Log.w("NotesViewModel", "Failed to decrypt note on permanent unlock, assuming plaintext.", e)
|
||||
decryptedContent = it.content
|
||||
}
|
||||
noteToUpdate = noteToUpdate.copy(
|
||||
content = decryptedContent
|
||||
)
|
||||
}
|
||||
noteshopRepository.updateNote(noteToUpdate)
|
||||
// Also update the details state
|
||||
_noteDetails.value = _noteDetails.value.copy(
|
||||
isLocked = newLockedState,
|
||||
title = noteToUpdate.title,
|
||||
content = noteToUpdate.content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val uiState: StateFlow<NotesUiState> = combine(
|
||||
noteshopRepository.getAllNotesStream(),
|
||||
@@ -110,91 +70,105 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
|
||||
title = note.title,
|
||||
content = note.content,
|
||||
displayOrder = note.displayOrder,
|
||||
isLocked = note.isLocked
|
||||
protectionHash = note.protectionHash,
|
||||
protectionType = note.protectionType,
|
||||
lockMethod = note.lockMethod
|
||||
)
|
||||
}
|
||||
|
||||
fun updateNoteDetails(noteDetails: NoteDetails) {
|
||||
_noteDetails.value = noteDetails
|
||||
}
|
||||
suspend fun saveNote(secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
|
||||
|
||||
suspend fun saveNote() {
|
||||
if (noteDetails.value.isValid()) {
|
||||
var currentNote = noteDetails.value.toNote()
|
||||
|
||||
if (noteDetails.value.isValid()) {
|
||||
|
||||
|
||||
var currentNote = noteDetails.value.toNote()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// If it's a new note that should be locked, encrypt its content before saving.
|
||||
|
||||
|
||||
if (currentNote.id == 0 && currentNote.isLocked && secretKey != null) {
|
||||
|
||||
|
||||
try {
|
||||
|
||||
|
||||
val encryptedContent = Base64.getEncoder().encodeToString(
|
||||
|
||||
|
||||
fileEncryptor.encrypt(currentNote.content.toByteArray(Charsets.UTF_8), secretKey)
|
||||
|
||||
|
||||
)
|
||||
|
||||
|
||||
currentNote = currentNote.copy(content = encryptedContent)
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
||||
|
||||
Log.e("NotesViewModel", "Failed to encrypt new note.", e)
|
||||
|
||||
|
||||
return // Prevent saving if encryption fails
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (currentNote.id == 0) {
|
||||
|
||||
|
||||
val notesCount = noteshopRepository.getNotesCount()
|
||||
|
||||
|
||||
currentNote = currentNote.copy(displayOrder = notesCount)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
noteshopRepository.insertNote(currentNote)
|
||||
|
||||
// Encryption on save is now handled by the new protection flow.
|
||||
|
||||
if (currentNote.id == 0) {
|
||||
val notesCount = noteshopRepository.getNotesCount()
|
||||
val newNote = currentNote.copy(displayOrder = notesCount)
|
||||
noteshopRepository.insertNote(newNote)
|
||||
} else {
|
||||
noteshopRepository.updateNote(currentNote)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteNote(note: Note) {
|
||||
noteshopRepository.deleteNote(note)
|
||||
}
|
||||
|
||||
fun setProtectionPassword(password: String) {
|
||||
viewModelScope.launch {
|
||||
val currentNoteDetails = _noteDetails.value
|
||||
if (password.isNotBlank()) {
|
||||
val hash = PasswordHasher.hashPassword(password)
|
||||
val updatedNote = currentNoteDetails.toNote().copy(
|
||||
protectionHash = hash,
|
||||
protectionType = 1, // 1 for password protection
|
||||
lockMethod = 1
|
||||
)
|
||||
noteshopRepository.updateNote(updatedNote)
|
||||
updateNoteDetails(updatedNote) // Update the UI state
|
||||
} else {
|
||||
// Password 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 setProtectionBiometric(noteId: Int) {
|
||||
viewModelScope.launch {
|
||||
val note = noteshopRepository.getNoteStream(noteId).firstOrNull()
|
||||
note?.let {
|
||||
val updatedNote = it.copy(
|
||||
protectionHash = "", // Clear password hash
|
||||
protectionType = 2, // 2 for biometric protection
|
||||
lockMethod = 2
|
||||
)
|
||||
noteshopRepository.updateNote(updatedNote)
|
||||
// No need to update _noteDetails.value here as it's for editing, not for the note itself
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProtectionPattern(pattern: String) {
|
||||
Log.d("NotesViewModel", "setProtectionPattern called with pattern: $pattern")
|
||||
}
|
||||
|
||||
fun setProtectionPin(pin: String) {
|
||||
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() {
|
||||
_noteDetails.value = NoteDetails()
|
||||
}
|
||||
@@ -234,14 +208,7 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun decryptAllLockedItems(secretKey: SecretKey, fileEncryptor: FileEncryptor) {
|
||||
val allNotes = noteshopRepository.getAllNotesStream().firstOrNull()
|
||||
allNotes?.forEach { note ->
|
||||
if (note.isLocked) {
|
||||
toggleNoteLock(note.id, secretKey, fileEncryptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TIMEOUT_MILLIS = 5_000L
|
||||
@@ -257,14 +224,18 @@ data class NoteDetails(
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val displayOrder: Int = 0,
|
||||
val isLocked: Boolean = false
|
||||
val protectionHash: String = "",
|
||||
val protectionType: Int = 0,
|
||||
val lockMethod: Int = 0
|
||||
) {
|
||||
fun toNote(): Note = Note(
|
||||
id = id,
|
||||
title = title,
|
||||
content = content,
|
||||
displayOrder = displayOrder,
|
||||
isLocked = isLocked
|
||||
protectionHash = protectionHash,
|
||||
protectionType = protectionType,
|
||||
lockMethod = lockMethod
|
||||
)
|
||||
|
||||
fun isValid(): Boolean {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.lxtools.noteshop.ui.notes
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.lxtools.noteshop.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SetPasswordDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onSetPassword: (String) -> Unit
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var showConfirmPassword by remember { mutableStateOf(false) }
|
||||
|
||||
val isNewPasswordValid = password.isNotBlank() && password == confirmPassword
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.set_password)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(text = stringResource(R.string.set_password_description))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
val image = if (showPassword) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
|
||||
val description = if (showPassword) "Hide password" else "Show password"
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(imageVector = image, description)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text(stringResource(R.string.confirm_password)) },
|
||||
visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
val image = if (showConfirmPassword) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
|
||||
val description = if (showConfirmPassword) "Hide password" else "Show password"
|
||||
IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) {
|
||||
Icon(imageVector = image, description)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onSetPassword(password) },
|
||||
enabled = isNewPasswordValid
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -39,11 +39,7 @@ import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import de.lxtools.noteshop.security.FileEncryptor
|
||||
import javax.crypto.SecretKey
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import de.lxtools.noteshop.findActivity
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -52,31 +48,21 @@ fun RecipeDetailScreen(
|
||||
recipeId: Int?,
|
||||
viewModel: RecipesViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onUnlockClick: () -> Unit,
|
||||
secretKey: SecretKey?,
|
||||
fileEncryptor: FileEncryptor
|
||||
wasInitiallyLocked: Boolean,
|
||||
isContentDecrypted: Boolean,
|
||||
onUnlockClick: (Int) -> Unit
|
||||
) {
|
||||
|
||||
val recipeDetails by viewModel.recipeDetails.collectAsState()
|
||||
var isEditMode by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var wasLockedInitially by rememberSaveable { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
LaunchedEffect(recipeDetails) {
|
||||
if (wasLockedInitially == null) { // This ensures we only set it once
|
||||
wasLockedInitially = recipeDetails.isLocked
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(recipeId) {
|
||||
onDispose {
|
||||
val activity = context.findActivity()
|
||||
if (!activity.isChangingConfigurations) {
|
||||
if (wasLockedInitially == true) {
|
||||
viewModel.toggleRecipeLock(recipeId ?: 0, secretKey, fileEncryptor)
|
||||
}
|
||||
// TODO: Handle lock state correctly
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,8 +76,8 @@ fun RecipeDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (recipeDetails.isLocked) {
|
||||
LockedScreenPlaceholder(onUnlockClick = onUnlockClick)
|
||||
if (recipeDetails.protectionHash.isNotEmpty() && !isContentDecrypted) {
|
||||
LockedScreenPlaceholder(onUnlockClick = { onUnlockClick(recipeDetails.protectionType) })
|
||||
} else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.lxtools.noteshop.ui.recipes
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
||||
@@ -46,7 +46,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
fun RecipesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RecipesViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||
onRecipeClick: (Int) -> Unit,
|
||||
onRecipeClick: (Int, Int) -> Unit,
|
||||
onEditRecipe: (Recipe) -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
@@ -57,7 +57,7 @@ fun RecipesScreen(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
if (uiState.recipeList.isEmpty()) {
|
||||
Text(text = "No recipes yet. Click + to add one!")
|
||||
Text(text = stringResource(R.string.no_recipes_yet))
|
||||
} else {
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
||||
@@ -80,7 +80,7 @@ fun RecipesScreen(
|
||||
if (!isReorderMode) {
|
||||
detectTapGestures(
|
||||
onLongPress = { viewModel.enableReorderMode() },
|
||||
onTap = { onRecipeClick(recipe.id) }
|
||||
onTap = { onRecipeClick(recipe.id, recipe.protectionType) }
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -127,7 +127,7 @@ fun RecipeCard(
|
||||
Icon(Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.reorder))
|
||||
}
|
||||
}
|
||||
if (recipe.isLocked) {
|
||||
if (recipe.lockMethod != 0) {
|
||||
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -14,9 +14,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import android.util.Log
|
||||
import de.lxtools.noteshop.security.FileEncryptor
|
||||
import java.util.Base64
|
||||
import javax.crypto.SecretKey
|
||||
import de.lxtools.noteshop.security.PasswordHasher
|
||||
|
||||
class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : ViewModel() {
|
||||
|
||||
@@ -38,48 +36,7 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun toggleRecipeLock(recipeId: Int, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val recipe = noteshopRepository.getRecipeStream(recipeId).firstOrNull()
|
||||
recipe?.let {
|
||||
val newLockedState = !it.isLocked
|
||||
var recipeToUpdate = it.copy(isLocked = newLockedState)
|
||||
if (newLockedState) {
|
||||
// Encrypt
|
||||
try {
|
||||
val encryptedContent = Base64.getEncoder().encodeToString(
|
||||
fileEncryptor.encrypt(it.content.toByteArray(Charsets.UTF_8), secretKey!!)
|
||||
)
|
||||
recipeToUpdate = recipeToUpdate.copy(
|
||||
content = encryptedContent
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RecipesViewModel", "Failed to encrypt recipe content on lock.", e)
|
||||
return@launch // Don't update if encryption fails
|
||||
}
|
||||
} else {
|
||||
// Decrypt
|
||||
var decryptedContent: String
|
||||
try {
|
||||
decryptedContent = String(fileEncryptor.decrypt(Base64.getDecoder().decode(it.content), secretKey!!), Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
Log.w("RecipesViewModel", "Failed to decrypt recipe on permanent unlock, assuming plaintext.", e)
|
||||
decryptedContent = it.content
|
||||
}
|
||||
recipeToUpdate = recipeToUpdate.copy(
|
||||
content = decryptedContent
|
||||
)
|
||||
}
|
||||
noteshopRepository.updateRecipe(recipeToUpdate)
|
||||
// Also update the details state
|
||||
_recipeDetails.value = _recipeDetails.value.copy(
|
||||
isLocked = newLockedState,
|
||||
title = recipeToUpdate.title,
|
||||
content = recipeToUpdate.content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val uiState: StateFlow<RecipesUiState> = combine(
|
||||
noteshopRepository.getAllRecipesStream(),
|
||||
@@ -110,95 +67,106 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
|
||||
title = recipe.title,
|
||||
content = recipe.content,
|
||||
displayOrder = recipe.displayOrder,
|
||||
isLocked = recipe.isLocked
|
||||
protectionHash = recipe.protectionHash,
|
||||
protectionType = recipe.protectionType,
|
||||
lockMethod = recipe.lockMethod
|
||||
)
|
||||
}
|
||||
|
||||
fun updateRecipeDetails(recipeDetails: RecipeDetails) {
|
||||
Log.d("RecipesViewModel", "updateRecipeDetails called with details: $recipeDetails")
|
||||
_recipeDetails.value = recipeDetails
|
||||
}
|
||||
|
||||
suspend fun saveRecipe(secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
|
||||
|
||||
|
||||
if (recipeDetails.value.isValid()) {
|
||||
|
||||
|
||||
var currentRecipe = recipeDetails.value.toRecipe()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// If it's a new recipe that should be locked, encrypt its content before saving.
|
||||
|
||||
|
||||
if (currentRecipe.id == 0 && currentRecipe.isLocked && secretKey != null) {
|
||||
|
||||
|
||||
try {
|
||||
|
||||
|
||||
val encryptedContent = Base64.getEncoder().encodeToString(
|
||||
|
||||
|
||||
fileEncryptor.encrypt(currentRecipe.content.toByteArray(Charsets.UTF_8), secretKey)
|
||||
|
||||
|
||||
)
|
||||
|
||||
|
||||
currentRecipe = currentRecipe.copy(content = encryptedContent)
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
||||
|
||||
Log.e("RecipesViewModel", "Failed to encrypt new recipe.", e)
|
||||
|
||||
|
||||
return // Prevent saving if encryption fails
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (currentRecipe.id == 0) {
|
||||
|
||||
|
||||
val recipesCount = noteshopRepository.getRecipesCount()
|
||||
|
||||
|
||||
val newRecipe = currentRecipe.copy(displayOrder = recipesCount)
|
||||
|
||||
|
||||
noteshopRepository.insertRecipe(newRecipe)
|
||||
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
noteshopRepository.updateRecipe(currentRecipe)
|
||||
|
||||
|
||||
}
|
||||
suspend fun saveRecipe() {
|
||||
if (recipeDetails.value.isValid()) {
|
||||
var currentRecipe = recipeDetails.value.toRecipe()
|
||||
|
||||
// Encryption on save is now handled by the new protection flow.
|
||||
|
||||
if (currentRecipe.id == 0) {
|
||||
val recipesCount = noteshopRepository.getRecipesCount()
|
||||
val newRecipe = currentRecipe.copy(displayOrder = recipesCount)
|
||||
noteshopRepository.insertRecipe(newRecipe)
|
||||
} else {
|
||||
noteshopRepository.updateRecipe(currentRecipe)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteRecipe(recipe: Recipe) {
|
||||
noteshopRepository.deleteRecipe(recipe)
|
||||
}
|
||||
|
||||
fun setProtectionPassword(password: String) {
|
||||
viewModelScope.launch {
|
||||
val currentRecipeDetails = _recipeDetails.value
|
||||
if (password.isNotBlank()) {
|
||||
val hash = PasswordHasher.hashPassword(password)
|
||||
val updatedRecipe = currentRecipeDetails.toRecipe().copy(
|
||||
protectionHash = hash,
|
||||
protectionType = 1, // 1 for password protection
|
||||
lockMethod = 1
|
||||
)
|
||||
noteshopRepository.updateRecipe(updatedRecipe)
|
||||
updateRecipeDetails(updatedRecipe) // Update the UI state
|
||||
} else {
|
||||
// Password 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 setProtectionBiometric(recipeId: Int) {
|
||||
viewModelScope.launch {
|
||||
val recipe = noteshopRepository.getRecipeStream(recipeId).firstOrNull()
|
||||
recipe?.let {
|
||||
val updatedRecipe = it.copy(
|
||||
protectionHash = "", // Clear password hash
|
||||
protectionType = 2, // 2 for biometric protection
|
||||
lockMethod = 2
|
||||
)
|
||||
noteshopRepository.updateRecipe(updatedRecipe)
|
||||
// No need to update _recipeDetails.value here as it's for editing, not for the recipe itself
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProtectionPattern(pattern: String) {
|
||||
Log.d("RecipesViewModel", "setProtectionPattern called with pattern: $pattern")
|
||||
}
|
||||
|
||||
fun setProtectionPin(pin: String) {
|
||||
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() {
|
||||
_recipeDetails.value = RecipeDetails()
|
||||
}
|
||||
@@ -238,14 +206,7 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun decryptAllLockedItems(secretKey: SecretKey, fileEncryptor: FileEncryptor) {
|
||||
val allRecipes = noteshopRepository.getAllRecipesStream().firstOrNull()
|
||||
allRecipes?.forEach { recipe ->
|
||||
if (recipe.isLocked) {
|
||||
toggleRecipeLock(recipe.id, secretKey, fileEncryptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TIMEOUT_MILLIS = 5_000L
|
||||
@@ -261,14 +222,18 @@ data class RecipeDetails(
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val displayOrder: Int = 0,
|
||||
val isLocked: Boolean = false
|
||||
val protectionHash: String = "",
|
||||
val protectionType: Int = 0,
|
||||
val lockMethod: Int = 0
|
||||
) {
|
||||
fun toRecipe(): Recipe = Recipe(
|
||||
id = id,
|
||||
title = title,
|
||||
content = content,
|
||||
displayOrder = displayOrder,
|
||||
isLocked = isLocked
|
||||
protectionHash = protectionHash,
|
||||
protectionType = protectionType,
|
||||
lockMethod = lockMethod
|
||||
)
|
||||
|
||||
fun isValid(): Boolean {
|
||||
|
||||
@@ -60,6 +60,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import de.lxtools.noteshop.findActivity
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShoppingListDetailScreen(
|
||||
@@ -67,11 +70,18 @@ fun ShoppingListDetailScreen(
|
||||
viewModel: ShoppingListsViewModel,
|
||||
dynamicStrings: de.lxtools.noteshop.DynamicListStrings,
|
||||
modifier: Modifier = Modifier,
|
||||
secretKey: SecretKey?,
|
||||
fileEncryptor: FileEncryptor,
|
||||
onUnlockClick: () -> Unit
|
||||
wasInitiallyLocked: Boolean,
|
||||
isContentDecrypted: Boolean,
|
||||
onUnlockClick: (Int) -> Unit,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
keyboardController?.hide()
|
||||
onNavigateBack()
|
||||
}
|
||||
|
||||
// Collect state from ViewModel
|
||||
val newItemName by viewModel.newItemName.collectAsState()
|
||||
@@ -85,25 +95,12 @@ fun ShoppingListDetailScreen(
|
||||
val isDetailSearchActive by viewModel.isDetailSearchActive.collectAsState()
|
||||
|
||||
|
||||
var wasLockedInitially by rememberSaveable { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
// This effect runs when the screen is first displayed. It records the initial lock state.
|
||||
LaunchedEffect(shoppingListWithItems) {
|
||||
if (wasLockedInitially == null) { // This ensures we only set it once
|
||||
wasLockedInitially = shoppingListWithItems?.shoppingList?.isLocked
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(listId) {
|
||||
onDispose {
|
||||
val activity = context.findActivity()
|
||||
if (!activity.isChangingConfigurations) {
|
||||
// We only re-lock if the list was locked when we entered.
|
||||
if (wasLockedInitially == true) {
|
||||
// Calling toggleListLock will re-lock the list if it's currently unlocked.
|
||||
viewModel.toggleListLock(listId ?: 0, secretKey, fileEncryptor)
|
||||
}
|
||||
// TODO: Handle lock state correctly
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,9 +119,9 @@ fun ShoppingListDetailScreen(
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
val listWithItems = shoppingListWithItems!!
|
||||
if (listWithItems.shoppingList.isLocked) {
|
||||
LockedScreenPlaceholder(onUnlockClick = onUnlockClick)
|
||||
} else {
|
||||
if (listWithItems.shoppingList.protectionHash.isNotEmpty() && !isContentDecrypted) {
|
||||
LockedScreenPlaceholder(onUnlockClick = { onUnlockClick(listWithItems.shoppingList.protectionType) })
|
||||
} else {
|
||||
val itemsForUi = listWithItems.items
|
||||
|
||||
val checkedCount = itemsForUi.count { it.isChecked }
|
||||
@@ -189,7 +186,7 @@ fun ShoppingListDetailScreen(
|
||||
.padding(vertical = 8.dp)
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
viewModel.saveShoppingListItem(item.copy(isChecked = false), secretKey, fileEncryptor)
|
||||
viewModel.saveShoppingListItem(item.copy(isChecked = false))
|
||||
val currentInput = viewModel.newItemName.value
|
||||
val lastCommaIndex = currentInput.lastIndexOf(',')
|
||||
val lastSpaceIndex = currentInput.lastIndexOf(' ')
|
||||
@@ -273,7 +270,7 @@ fun ShoppingListDetailScreen(
|
||||
onTap = {
|
||||
coroutineScope.launch {
|
||||
val updatedItem = item.copy(isChecked = !item.isChecked)
|
||||
viewModel.saveShoppingListItem(updatedItem, secretKey, fileEncryptor)
|
||||
viewModel.saveShoppingListItem(updatedItem)
|
||||
viewModel.markItemInWebApp(item.name)
|
||||
}
|
||||
}
|
||||
@@ -343,7 +340,7 @@ fun ShoppingListDetailScreen(
|
||||
onDismiss = { viewModel.onShowRenameDialog(false) },
|
||||
onRename = { newName ->
|
||||
coroutineScope.launch {
|
||||
viewModel.saveShoppingListItem(selectedItem!!.copy(name = newName), secretKey, fileEncryptor)
|
||||
viewModel.saveShoppingListItem(selectedItem!!.copy(name = newName))
|
||||
}
|
||||
viewModel.onShowRenameDialog(false)
|
||||
viewModel.onSelectItem(null)
|
||||
@@ -358,7 +355,7 @@ fun ShoppingListDetailScreen(
|
||||
onDismiss = { viewModel.onShowQuantityDialog(false) },
|
||||
onSetQuantity = { quantity ->
|
||||
coroutineScope.launch {
|
||||
viewModel.saveShoppingListItem(selectedItem!!.copy(quantity = quantity), secretKey, fileEncryptor)
|
||||
viewModel.saveShoppingListItem(selectedItem!!.copy(quantity = quantity))
|
||||
}
|
||||
viewModel.onShowQuantityDialog(false)
|
||||
viewModel.onSelectItem(null)
|
||||
@@ -396,6 +393,8 @@ fun LockedScreenPlaceholder(onUnlockClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun RenameItemDialog(item: ShoppingListItem, onDismiss: () -> Unit, onRename: (String) -> Unit, dynamicStrings: de.lxtools.noteshop.DynamicListStrings) {
|
||||
var text by remember { mutableStateOf(item.name) }
|
||||
|
||||
@@ -51,7 +51,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
fun ShoppingListsScreen(
|
||||
viewModel: ShoppingListsViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier,
|
||||
onListClick: (Int) -> Unit,
|
||||
onListClick: (Int, Int) -> Unit,
|
||||
onEditList: (ShoppingListWithItems) -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
@@ -86,7 +86,7 @@ fun ShoppingListsScreen(
|
||||
if (!isReorderMode) {
|
||||
detectTapGestures(
|
||||
onLongPress = { viewModel.enableReorderMode() },
|
||||
onTap = { onListClick(listWithItems.shoppingList.id) }
|
||||
onTap = { onListClick(listWithItems.shoppingList.id, listWithItems.shoppingList.protectionType) }
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -127,7 +127,7 @@ fun ShoppingListCard(
|
||||
Icon(Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.reorder))
|
||||
}
|
||||
}
|
||||
if (listWithItems.shoppingList.isLocked) {
|
||||
if (listWithItems.shoppingList.lockMethod != 0) {
|
||||
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json
|
||||
import de.lxtools.noteshop.security.FileEncryptor
|
||||
import java.util.Base64
|
||||
import javax.crypto.SecretKey
|
||||
import de.lxtools.noteshop.security.PasswordHasher
|
||||
|
||||
class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository, application: Application) : AndroidViewModel(application) {
|
||||
|
||||
@@ -55,61 +56,7 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
|
||||
|
||||
|
||||
|
||||
fun toggleListLock(listId: Int, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val listWithItems = noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()
|
||||
listWithItems?.let {
|
||||
val newLockedState = !it.shoppingList.isLocked
|
||||
var listToUpdate = it.shoppingList.copy(isLocked = newLockedState)
|
||||
|
||||
if (newLockedState) {
|
||||
// Encrypt items
|
||||
if (secretKey != null) {
|
||||
try {
|
||||
val itemsJson = Json.encodeToString(it.items)
|
||||
val encryptedItems = Base64.getEncoder().encodeToString(fileEncryptor.encrypt(itemsJson.toByteArray(Charsets.UTF_8), secretKey))
|
||||
listToUpdate = listToUpdate.copy(encryptedItems = encryptedItems)
|
||||
noteshopRepository.updateShoppingList(listToUpdate)
|
||||
if (it.items.isNotEmpty()) {
|
||||
noteshopRepository.deleteShoppingListItems(it.items)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ShoppingListsViewModel", "Failed to encrypt list on lock.", e)
|
||||
return@launch
|
||||
}
|
||||
} else {
|
||||
// No key provided, cannot lock
|
||||
return@launch
|
||||
}
|
||||
} else {
|
||||
// Decrypt items
|
||||
if (secretKey != null && it.shoppingList.encryptedItems != null) {
|
||||
try {
|
||||
val decryptedItemsJson = String(fileEncryptor.decrypt(Base64.getDecoder().decode(it.shoppingList.encryptedItems), secretKey), Charsets.UTF_8)
|
||||
val decryptedItems = Json.decodeFromString<List<ShoppingListItem>>(decryptedItemsJson)
|
||||
listToUpdate = listToUpdate.copy(encryptedItems = null)
|
||||
noteshopRepository.updateShoppingList(listToUpdate)
|
||||
if (decryptedItems.isNotEmpty()) {
|
||||
val itemsToInsert = decryptedItems.map { it.copy(id = 0) }
|
||||
noteshopRepository.insertShoppingListItems(itemsToInsert)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ShoppingListsViewModel", "Failed to decrypt list on unlock.", e)
|
||||
return@launch // Abort on decryption failure
|
||||
}
|
||||
} else {
|
||||
// No key or no encrypted data, just unlock
|
||||
listToUpdate = listToUpdate.copy(encryptedItems = null)
|
||||
noteshopRepository.updateShoppingList(listToUpdate)
|
||||
}
|
||||
}
|
||||
_listDetails.value = _listDetails.value.copy(
|
||||
isLocked = newLockedState,
|
||||
name = listToUpdate.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNewItemNameChange(name: String) {
|
||||
_newItemName.value = name
|
||||
@@ -166,7 +113,9 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
|
||||
id = list.id,
|
||||
name = list.name,
|
||||
displayOrder = list.displayOrder,
|
||||
isLocked = list.isLocked
|
||||
protectionHash = list.protectionHash,
|
||||
protectionType = list.protectionType,
|
||||
lockMethod = list.lockMethod
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,23 +124,11 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
|
||||
}
|
||||
|
||||
|
||||
suspend fun saveList(secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
|
||||
suspend fun saveList() {
|
||||
if (listDetails.value.isValid()) {
|
||||
var currentList = listDetails.value.toShoppingList()
|
||||
|
||||
// If it's a new list that should be locked, encrypt an empty item list.
|
||||
if (currentList.id == 0 && currentList.isLocked && secretKey != null) {
|
||||
try {
|
||||
val emptyItemsJson = "[]" // Empty JSON array for an empty list
|
||||
val encryptedItems = Base64.getEncoder().encodeToString(
|
||||
fileEncryptor.encrypt(emptyItemsJson.toByteArray(Charsets.UTF_8), secretKey)
|
||||
)
|
||||
currentList = currentList.copy(encryptedItems = encryptedItems)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ShoppingListsViewModel", "Failed to encrypt new empty list.", e)
|
||||
return // Prevent saving if encryption fails
|
||||
}
|
||||
}
|
||||
// Encryption on save is now handled by the new protection flow.
|
||||
|
||||
if (currentList.id == 0) {
|
||||
val listsCount = noteshopRepository.getListsCount()
|
||||
@@ -202,13 +139,89 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
|
||||
val listToUpdate = existingList?.copy(
|
||||
name = currentList.name,
|
||||
displayOrder = currentList.displayOrder,
|
||||
isLocked = currentList.isLocked
|
||||
protectionHash = currentList.protectionHash,
|
||||
protectionType = currentList.protectionType,
|
||||
lockMethod = currentList.lockMethod
|
||||
) ?: currentList
|
||||
noteshopRepository.updateShoppingList(listToUpdate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProtection(listId: Int, password: String) {
|
||||
viewModelScope.launch {
|
||||
val listToUpdate = noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()?.shoppingList
|
||||
|
||||
listToUpdate?.let { existingList ->
|
||||
if (password.isNotBlank()) {
|
||||
val hash = PasswordHasher.hashPassword(password)
|
||||
val updatedList = existingList.copy(
|
||||
protectionHash = hash,
|
||||
protectionType = 1, // 1 for password protection
|
||||
lockMethod = 1
|
||||
)
|
||||
noteshopRepository.updateShoppingList(updatedList)
|
||||
updateListDetails(updatedList) // Update the UI state
|
||||
} else {
|
||||
// Password is blank, so we remove protection
|
||||
val updatedList = existingList.copy(
|
||||
protectionHash = "",
|
||||
protectionType = 0,
|
||||
lockMethod = 0
|
||||
)
|
||||
noteshopRepository.updateShoppingList(updatedList)
|
||||
updateListDetails(updatedList) // Update the UI state
|
||||
}
|
||||
} ?: run {
|
||||
Log.e("ShoppingListsViewModel", "Shopping list with ID $listId not found for protection update or is unsaved.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProtectionBiometric(listId: Int) {
|
||||
viewModelScope.launch {
|
||||
val list = noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()?.shoppingList
|
||||
list?.let {
|
||||
val updatedList = it.copy(
|
||||
protectionHash = "", // Clear password hash
|
||||
protectionType = 2, // 2 for biometric protection
|
||||
lockMethod = 2
|
||||
)
|
||||
noteshopRepository.updateShoppingList(updatedList)
|
||||
// No need to update _listDetails.value here as it's for editing, not for the list itself
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProtectionPattern(pattern: String) {
|
||||
Log.d("ShoppingListsViewModel", "setProtectionPattern called with pattern: $pattern")
|
||||
}
|
||||
|
||||
fun setProtectionPin(pin: String) {
|
||||
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) {
|
||||
noteshopRepository.deleteShoppingList(list)
|
||||
}
|
||||
@@ -221,28 +234,29 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
|
||||
|
||||
|
||||
|
||||
suspend fun saveShoppingListItem(item: ShoppingListItem, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
|
||||
suspend fun saveShoppingListItem(item: ShoppingListItem) {
|
||||
if (item.name.isNotBlank()) {
|
||||
val parentList = noteshopRepository.getShoppingListWithItemsStream(item.listId).firstOrNull()?.shoppingList
|
||||
if (parentList?.isLocked == true) {
|
||||
if (parentList?.protectionHash?.isNotEmpty() == true) {
|
||||
Log.w("ShoppingListsViewModel", "Attempted to save item to a locked list.")
|
||||
return
|
||||
}
|
||||
|
||||
var currentItem = item
|
||||
if (currentItem.id == 0) {
|
||||
val itemsCount = noteshopRepository.getItemsCount(currentItem.listId)
|
||||
currentItem = currentItem.copy(displayOrder = itemsCount)
|
||||
if (item.id == 0) {
|
||||
val itemsCount = noteshopRepository.getItemsCount(item.listId)
|
||||
val newItem = item.copy(displayOrder = itemsCount)
|
||||
noteshopRepository.insertShoppingListItem(newItem)
|
||||
} else {
|
||||
noteshopRepository.updateShoppingListItem(item)
|
||||
}
|
||||
noteshopRepository.insertShoppingListItem(currentItem)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveShoppingListItem(listId: Int, itemName: String, secretKey: SecretKey?, fileEncryptor: FileEncryptor, shouldSplit: Boolean = true) {
|
||||
suspend fun saveShoppingListItem(listId: Int, itemName: String, shouldSplit: Boolean = true) {
|
||||
if (itemName.isNotBlank()) {
|
||||
val listWithItems = noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()
|
||||
listWithItems?.let {
|
||||
if (it.shoppingList.isLocked) {
|
||||
if (it.shoppingList.protectionHash.isNotEmpty()) {
|
||||
Log.w("ShoppingListsViewModel", "Attempted to save items to a locked list.")
|
||||
return
|
||||
}
|
||||
@@ -425,14 +439,7 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun decryptAllLockedItems(secretKey: SecretKey, fileEncryptor: FileEncryptor) {
|
||||
val allLists = noteshopRepository.getAllShoppingListsWithItemsStream().firstOrNull()
|
||||
allLists?.forEach { listWithItems ->
|
||||
if (listWithItems.shoppingList.isLocked) {
|
||||
toggleListLock(listWithItems.shoppingList.id, secretKey, fileEncryptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun importItemsFromWebApp(listId: Int) {
|
||||
viewModelScope.launch {
|
||||
@@ -603,13 +610,17 @@ data class ShoppingListDetails(
|
||||
val id: Int = 0,
|
||||
val name: String = "",
|
||||
val displayOrder: Int = 0,
|
||||
val isLocked: Boolean = false
|
||||
val protectionHash: String = "",
|
||||
val protectionType: Int = 0,
|
||||
val lockMethod: Int = 0
|
||||
) {
|
||||
fun toShoppingList(): ShoppingList = ShoppingList(
|
||||
id = id,
|
||||
name = name,
|
||||
displayOrder = displayOrder,
|
||||
isLocked = isLocked
|
||||
protectionHash = protectionHash,
|
||||
protectionType = protectionType,
|
||||
lockMethod = lockMethod
|
||||
)
|
||||
|
||||
fun isValid(): Boolean {
|
||||
|
||||
@@ -232,6 +232,13 @@
|
||||
<string name="unlock_list">Sperre entfernen</string>
|
||||
<string name="lock_note">Notiz sperren</string>
|
||||
<string name="unlock_note">Sperre entfernen</string>
|
||||
<string name="set_password">Passwort festlegen</string>
|
||||
<string name="set_password_description">Geben Sie ein Passwort ein, um dieses Element zu schützen. Dieses Passwort wird benötigt, um es anzuzeigen oder zu bearbeiten.</string>
|
||||
<string name="unlock_item">Element entsperren</string>
|
||||
<string name="enter_password_to_unlock">Geben Sie das Passwort ein, um dieses Element zu entsperren.</string>
|
||||
<string name="incorrect_password">Falsches Passwort.</string>
|
||||
<string name="unlock_encryption">Verschlüsselung entsperren</string>
|
||||
<string name="enter_password_to_unlock_encryption">Geben Sie das Passwort ein, um die Datenverschlüsselung zu entsperren.</string>
|
||||
|
||||
<string name="data_encryption">Datenverschlüsselung</string>
|
||||
<string name="set_encryption_password">Verschlüsselungspasswort festlegen</string>
|
||||
@@ -319,4 +326,14 @@
|
||||
<string name="json_import_failed_invalid_format">JSON-Import fehlgeschlagen: Ungültiges Format. %1$s</string>
|
||||
<string name="json_import_failed_generic">JSON-Import fehlgeschlagen: %1$s</string>
|
||||
<string name="json_import_failed_no_data">JSON-Import fehlgeschlagen: Keine Daten zum Importieren.</string>
|
||||
<string name="lock">Sperren</string>
|
||||
<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>
|
||||
@@ -1,5 +1,6 @@
|
||||
<resources>
|
||||
<string name="biometric_required_to_unlock">A fingerprint sensor is required to unlock this item.</string>
|
||||
<string name="biometric_lock">Biometric Lock</string>
|
||||
<string name="app_name">Noteshop</string>
|
||||
<string name="app_title">Noteshop</string>
|
||||
<string name="menu_notes">Notes</string>
|
||||
@@ -232,7 +233,14 @@
|
||||
<string name="unlock_list">Remove lock</string>
|
||||
<string name="lock_note">Lock note</string>
|
||||
<string name="unlock_note">Remove lock</string>
|
||||
|
||||
<string name="set_password">Set Password</string>
|
||||
<string name="set_password_description">Enter a password to protect this item. This password will be required to view or edit it.</string>
|
||||
<string name="unlock_item">Unlock Item</string>
|
||||
<string name="enter_password_to_unlock">Enter password to unlock this item.</string>
|
||||
<string name="incorrect_password">Incorrect password.</string>
|
||||
<string name="unlock_encryption">Unlock Encryption</string>
|
||||
<string name="enter_password_to_unlock_encryption">Enter password to unlock data encryption.</string>
|
||||
|
||||
<string name="data_encryption">Data Encryption</string>
|
||||
<string name="set_encryption_password">Set Encryption Password</string>
|
||||
<string name="change_encryption_password">Change Encryption Password</string>
|
||||
@@ -319,4 +327,13 @@
|
||||
<string name="json_import_failed_invalid_format">JSON import failed: Invalid format. %1$s</string>
|
||||
<string name="json_import_failed_generic">JSON import failed: %1$s</string>
|
||||
<string name="json_import_failed_no_data">JSON import failed: No data to import.</string>
|
||||
<string name="lock">Lock</string>
|
||||
<string name="remove_lock">Remove lock</string>
|
||||
<string name="choose_lock_method">Choose lock method</string>
|
||||
<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>
|
||||
Reference in New Issue
Block a user