Fix: ShoppingList protection not applying

Updated the  function in  to directly accept  as a parameter. This ensures that the correct  is fetched from the repository and its protection status is updated, addressing the issue where protection was not being applied due to an incorrect or uninitialized  from the UI state.

Note: This commit also includes other uncommitted changes as requested by the user.
This commit is contained in:
2025-11-02 21:20:18 +01:00
parent 416f1e37aa
commit f613e27c6d
27 changed files with 3126 additions and 2198 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [Note::class, ShoppingList::class, ShoppingListItem::class, Recipe::class],
version = 8, // Incremented version
version = 9, // 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)
.build()
INSTANCE = instance
// return instance
@@ -82,5 +82,16 @@ 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")
}
}
}
}

View File

@@ -12,5 +12,6 @@ data class Note(
val title: String,
val content: String,
val displayOrder: Int = 0,
val isLocked: Boolean = false
val protectionHash: String = "",
val protectionType: Int = 0
)

View File

@@ -12,5 +12,6 @@ data class Recipe(
val title: String,
val content: String,
val displayOrder: Int = 0,
val isLocked: Boolean = false
val protectionHash: String = "",
val protectionType: Int = 0
)

View File

@@ -11,6 +11,7 @@ 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 encryptedItems: String? = null
)

View File

@@ -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) }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
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 {
PASSWORD,
BIOMETRIC
}
@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 { 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))
}
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))
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(selectedMethod) }) {
Text(text = stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.cancel))
}
}
)
}

View File

@@ -0,0 +1,7 @@
package de.lxtools.noteshop.ui
enum class LockableItemType {
SHOPPING_LIST,
NOTE,
RECIPE
}

View 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))
}
}
)
}

View File

@@ -0,0 +1,66 @@
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.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
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.PasswordVisualTransformation
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("") }
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 = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
errorMessage?.let { message ->
Text(text = message, 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))
}
}
)
}

View File

@@ -0,0 +1,215 @@
package de.lxtools.noteshop.ui.appshell
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) }
)
}
}
}
}

View File

@@ -0,0 +1,282 @@
package de.lxtools.noteshop.ui.appshell
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.LockMethod
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
import kotlinx.coroutines.launch
@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
) {
val scope = rememberCoroutineScope()
val dynamicRecipeStrings = getDynamicRecipeStrings(recipesTitle)
if (showListDialog) {
ShoppingListInputDialog(
listDetails = listDetails,
onValueChange = onListDetailsChange,
onSave = {
scope.launch {
onSaveList()
onShowListDialogChange(false)
onResetListDetails()
}
},
onDismiss = { onShowListDialogChange(false) },
onImportFromTxt = {
txtImportLauncher.launch(arrayOf("text/plain"))
},
isNewList = listDetails.id == 0
)
}
if (showNoteDialog) {
NoteInputDialog(
noteDetails = noteDetails,
onValueChange = onNoteDetailsChange,
onSave = {
scope.launch {
onSaveNote()
onShowNoteDialogChange(false)
onResetNoteDetails()
}
},
onDismiss = { onShowNoteDialogChange(false) },
onImport = {
noteImportLauncher.launch(arrayOf("text/plain"))
onShowNoteDialogChange(false)
},
isNewNote = noteDetails.id == 0
)
}
if (showRecipeDialog) {
RecipeInputDialog(
recipeDetails = recipeDetails,
onValueChange = onRecipeDetailsChange,
onSave = {
scope.launch {
onSaveRecipe()
onShowRecipeDialogChange(false)
onResetRecipeDetails()
}
},
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 (showUnlockPasswordDialog) {
UnlockPasswordDialog(
onDismiss = {
onShowUnlockPasswordDialogChange(false)
},
onUnlock = onUnlock,
errorMessage = unlockErrorMessage
)
}
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 (showChooseLockMethodDialog) {
ChooseLockMethodDialog(
onDismiss = { onShowChooseLockMethodDialogChange(false) },
onConfirm = { lockMethod ->
onConfirmLockMethod(lockMethod)
},
canUseBiometrics = canUseBiometrics
)
}
}

View 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)
)
}
)
}
}
}
}

View File

@@ -0,0 +1,529 @@
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.set_password)) },
enabled = hasEncryptionPassword,
onClick = {
showMenu = false
onShowSetListPasswordDialog()
}
)
}
}
}
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.set_password)) },
enabled = hasEncryptionPassword,
onClick = {
showMenu = false
onShowSetPasswordDialog()
}
)
}
}
}
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.set_password)) },
enabled = hasEncryptionPassword,
onClick = {
showMenu = false
onShowSetRecipePasswordDialog()
}
)
}
}
}
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 -> {}
}
}
}

View File

@@ -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

View File

@@ -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.protectionHash.isNotEmpty()) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
}
Text(

View File

@@ -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,71 @@ 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
)
}
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()
currentNote = currentNote.copy(displayOrder = notesCount)
}
noteshopRepository.insertNote(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
)
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
)
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
)
noteshopRepository.updateNote(updatedNote)
// No need to update _noteDetails.value here as it's for editing, not for the note itself
}
}
}
fun resetNoteDetails() {
_noteDetails.value = NoteDetails()
}
@@ -234,14 +174,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 +190,16 @@ data class NoteDetails(
val title: String = "",
val content: String = "",
val displayOrder: Int = 0,
val isLocked: Boolean = false
val protectionHash: String = "",
val protectionType: Int = 0
) {
fun toNote(): Note = Note(
id = id,
title = title,
content = content,
displayOrder = displayOrder,
isLocked = isLocked
protectionHash = protectionHash,
protectionType = protectionType
)
fun isValid(): Boolean {

View File

@@ -0,0 +1,62 @@
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.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
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.PasswordVisualTransformation
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("") }
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 = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(
onClick = { onSetPassword(password) },
enabled = password.isNotBlank()
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

View File

@@ -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

View File

@@ -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.protectionHash.isNotEmpty()) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
}
Text(

View File

@@ -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,7 +67,8 @@ 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
)
}
@@ -118,87 +76,63 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
_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
)
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
)
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
)
noteshopRepository.updateRecipe(updatedRecipe)
// No need to update _recipeDetails.value here as it's for editing, not for the recipe itself
}
}
}
fun resetRecipeDetails() {
_recipeDetails.value = RecipeDetails()
}
@@ -238,14 +172,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 +188,16 @@ data class RecipeDetails(
val title: String = "",
val content: String = "",
val displayOrder: Int = 0,
val isLocked: Boolean = false
val protectionHash: String = "",
val protectionType: Int = 0
) {
fun toRecipe(): Recipe = Recipe(
id = id,
title = title,
content = content,
displayOrder = displayOrder,
isLocked = isLocked
protectionHash = protectionHash,
protectionType = protectionType
)
fun isValid(): Boolean {

View File

@@ -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) }

View File

@@ -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.protectionHash.isNotEmpty()) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
}
Text(

View File

@@ -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,8 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
id = list.id,
name = list.name,
displayOrder = list.displayOrder,
isLocked = list.isLocked
protectionHash = list.protectionHash,
protectionType = list.protectionType
)
}
@@ -175,23 +123,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 +138,56 @@ 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
) ?: 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
)
noteshopRepository.updateShoppingList(updatedList)
updateListDetails(updatedList) // Update the UI state
} else {
// Password is blank, so we remove protection
val updatedList = existingList.copy(
protectionHash = "",
protectionType = 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
)
noteshopRepository.updateShoppingList(updatedList)
// No need to update _listDetails.value here as it's for editing, not for the list itself
}
}
}
suspend fun deleteList(list: ShoppingList) {
noteshopRepository.deleteShoppingList(list)
}
@@ -221,10 +200,10 @@ 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
}
@@ -238,11 +217,11 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
}
}
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 +404,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 +575,15 @@ 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
) {
fun toShoppingList(): ShoppingList = ShoppingList(
id = id,
name = name,
displayOrder = displayOrder,
isLocked = isLocked
protectionHash = protectionHash,
protectionType = protectionType
)
fun isValid(): Boolean {

View File

@@ -232,6 +232,11 @@
<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="data_encryption">Datenverschlüsselung</string>
<string name="set_encryption_password">Verschlüsselungspasswort festlegen</string>
@@ -319,4 +324,8 @@
<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>
</resources>

View File

@@ -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,6 +233,11 @@
<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="data_encryption">Data Encryption</string>
<string name="set_encryption_password">Set Encryption Password</string>
@@ -319,4 +325,8 @@
<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>
</resources>