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:
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 = 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
1291
app/src/main/java/de/lxtools/noteshop/ui/AppShell.kt
Normal file
1291
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,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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.lxtools.noteshop.ui
|
||||
|
||||
enum class LockableItemType {
|
||||
SHOPPING_LIST,
|
||||
NOTE,
|
||||
RECIPE
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
215
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppContent.kt
Normal file
215
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppContent.kt
Normal 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
282
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppDialogs.kt
Normal file
282
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppDialogs.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
529
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppTopBar.kt
Normal file
529
app/src/main/java/de/lxtools/noteshop/ui/appshell/AppTopBar.kt
Normal 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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.protectionHash.isNotEmpty()) {
|
||||
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,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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.protectionHash.isNotEmpty()) {
|
||||
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,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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user