debug: v1.7.0 Features - Grid Layout, WiFi-only Sync, VPN Support

This commit is contained in:
inventory69
2026-01-26 21:19:46 +01:00
parent 217a174478
commit b70bc4d8f6
42 changed files with 2491 additions and 60 deletions

View File

@@ -31,20 +31,24 @@ class BackupManager(private val context: Context) {
private const val BACKUP_VERSION = 1
private const val AUTO_BACKUP_DIR = "auto_backups"
private const val AUTO_BACKUP_RETENTION_DAYS = 7
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
}
private val storage = NotesStorage(context)
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
/**
* Erstellt Backup aller Notizen
*
* @param uri Output-URI (via Storage Access Framework)
* @param password Optional password for encryption (null = unencrypted)
* @return BackupResult mit Erfolg/Fehler Info
*/
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
suspend fun createBackup(uri: Uri, password: String? = null): BackupResult = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "📦 Creating backup to: $uri")
val encryptedSuffix = if (password != null) " (encrypted)" else ""
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
val allNotes = storage.loadAllNotes()
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
@@ -59,15 +63,22 @@ class BackupManager(private val context: Context) {
val jsonString = gson.toJson(backupData)
// 🔐 v1.7.0: Encrypt if password is provided
val dataToWrite = if (password != null) {
encryptionManager.encrypt(jsonString.toByteArray(), password)
} else {
jsonString.toByteArray()
}
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(jsonString.toByteArray())
Logger.d(TAG, "✅ Backup created successfully")
outputStream.write(dataToWrite)
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
}
BackupResult(
success = true,
notesCount = allNotes.size,
message = "Backup erstellt: ${allNotes.size} Notizen"
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
)
} catch (e: Exception) {
@@ -126,20 +137,42 @@ class BackupManager(private val context: Context) {
*
* @param uri Backup-Datei URI
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
* @param password Optional password if backup is encrypted
* @return RestoreResult mit Details
*/
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
// 1. Backup-Datei lesen
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.bufferedReader().use { it.readText() }
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.readBytes()
} ?: return@withContext RestoreResult(
success = false,
error = "Datei konnte nicht gelesen werden"
)
// 🔐 v1.7.0: Check if encrypted and decrypt if needed
val jsonString = try {
if (encryptionManager.isEncrypted(fileData)) {
if (password == null) {
return@withContext RestoreResult(
success = false,
error = "Backup ist verschlüsselt. Bitte Passwort eingeben."
)
}
val decrypted = encryptionManager.decrypt(fileData, password)
String(decrypted)
} else {
String(fileData)
}
} catch (e: EncryptionException) {
return@withContext RestoreResult(
success = false,
error = "Entschlüsselung fehlgeschlagen: ${e.message}"
)
}
// 2. Backup validieren & parsen
val validationResult = validateBackup(jsonString)
if (!validationResult.isValid) {
@@ -177,6 +210,22 @@ class BackupManager(private val context: Context) {
}
}
/**
* 🔐 v1.7.0: Check if backup file is encrypted
*/
suspend fun isBackupEncrypted(uri: Uri): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val header = ByteArray(MAGIC_BYTES_LENGTH)
val bytesRead = inputStream.read(header)
bytesRead == MAGIC_BYTES_LENGTH && encryptionManager.isEncrypted(header)
} ?: false
} catch (e: Exception) {
Logger.e(TAG, "Failed to check encryption status", e)
false
}
}
/**
* Validiert Backup-Datei
*/

View File

@@ -0,0 +1,172 @@
package dev.dettmer.simplenotes.backup
import dev.dettmer.simplenotes.utils.Logger
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/**
* 🔐 v1.7.0: Encryption Manager for Backup Files
*
* Provides AES-256-GCM encryption for local backups with:
* - Password-based encryption (PBKDF2 key derivation)
* - Random salt + IV for each encryption
* - GCM authentication tag for integrity
* - Simple file format: [MAGIC][VERSION][SALT][IV][ENCRYPTED_DATA]
*/
class EncryptionManager {
companion object {
private const val TAG = "EncryptionManager"
// File format constants
private const val MAGIC = "SNE1" // Simple Notes Encrypted v1
private const val VERSION: Byte = 1
private const val MAGIC_BYTES = 4
private const val VERSION_BYTES = 1
private const val SALT_LENGTH = 32 // 256 bits
private const val IV_LENGTH = 12 // 96 bits (recommended for GCM)
private const val HEADER_LENGTH = MAGIC_BYTES + VERSION_BYTES + SALT_LENGTH + IV_LENGTH // 49 bytes
// Encryption constants
private const val KEY_LENGTH = 256 // AES-256
private const val GCM_TAG_LENGTH = 128 // 128 bits
private const val PBKDF2_ITERATIONS = 100_000 // OWASP recommendation
// Algorithm names
private const val KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256"
private const val ENCRYPTION_ALGORITHM = "AES/GCM/NoPadding"
}
/**
* Encrypt data with password
*
* @param data Plaintext data to encrypt
* @param password User password
* @return Encrypted byte array with header [MAGIC][VERSION][SALT][IV][CIPHERTEXT]
*/
fun encrypt(data: ByteArray, password: String): ByteArray {
Logger.d(TAG, "🔐 Encrypting ${data.size} bytes...")
// Generate random salt and IV
val salt = ByteArray(SALT_LENGTH)
val iv = ByteArray(IV_LENGTH)
SecureRandom().apply {
nextBytes(salt)
nextBytes(iv)
}
// Derive encryption key from password
val key = deriveKey(password, salt)
// Encrypt data
val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM)
val secretKey = SecretKeySpec(key, "AES")
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
val ciphertext = cipher.doFinal(data)
// Build encrypted file: MAGIC + VERSION + SALT + IV + CIPHERTEXT
val result = ByteBuffer.allocate(HEADER_LENGTH + ciphertext.size).apply {
put(MAGIC.toByteArray(StandardCharsets.US_ASCII))
put(VERSION)
put(salt)
put(iv)
put(ciphertext)
}.array()
Logger.d(TAG, "✅ Encryption successful: ${result.size} bytes (header: $HEADER_LENGTH, ciphertext: ${ciphertext.size})")
return result
}
/**
* Decrypt data with password
*
* @param encryptedData Encrypted byte array (with header)
* @param password User password
* @return Decrypted plaintext
* @throws EncryptionException if decryption fails (wrong password, corrupted data, etc.)
*/
@Suppress("ThrowsCount") // Multiple validation steps require separate throws
fun decrypt(encryptedData: ByteArray, password: String): ByteArray {
Logger.d(TAG, "🔓 Decrypting ${encryptedData.size} bytes...")
// Validate minimum size
if (encryptedData.size < HEADER_LENGTH) {
throw EncryptionException("File too small: ${encryptedData.size} bytes (expected at least $HEADER_LENGTH)")
}
// Parse header
val buffer = ByteBuffer.wrap(encryptedData)
// Verify magic bytes
val magic = ByteArray(MAGIC_BYTES)
buffer.get(magic)
val magicString = String(magic, StandardCharsets.US_ASCII)
if (magicString != MAGIC) {
throw EncryptionException("Invalid file format: expected '$MAGIC', got '$magicString'")
}
// Check version
val version = buffer.get()
if (version != VERSION) {
throw EncryptionException("Unsupported version: $version (expected $VERSION)")
}
// Extract salt and IV
val salt = ByteArray(SALT_LENGTH)
val iv = ByteArray(IV_LENGTH)
buffer.get(salt)
buffer.get(iv)
// Extract ciphertext
val ciphertext = ByteArray(buffer.remaining())
buffer.get(ciphertext)
// Derive key from password
val key = deriveKey(password, salt)
// Decrypt
return try {
val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM)
val secretKey = SecretKeySpec(key, "AES")
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
val plaintext = cipher.doFinal(ciphertext)
Logger.d(TAG, "✅ Decryption successful: ${plaintext.size} bytes")
plaintext
} catch (e: Exception) {
Logger.e(TAG, "Decryption failed", e)
throw EncryptionException("Decryption failed: ${e.message}. Wrong password?", e)
}
}
/**
* Derive 256-bit encryption key from password using PBKDF2
*/
private fun deriveKey(password: String, salt: ByteArray): ByteArray {
val spec = PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH)
val factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM)
return factory.generateSecret(spec).encoded
}
/**
* Check if data is encrypted (starts with magic bytes)
*/
fun isEncrypted(data: ByteArray): Boolean {
if (data.size < MAGIC_BYTES) return false
val magic = data.sliceArray(0 until MAGIC_BYTES)
return String(magic, StandardCharsets.US_ASCII) == MAGIC
}
}
/**
* Exception thrown when encryption/decryption fails
*/
class EncryptionException(message: String, cause: Throwable? = null) : Exception(message, cause)

View File

@@ -323,6 +323,34 @@ type: ${noteType.name.lowercase()}
}
}
/**
* 🎨 v1.7.0: Note size classification for Staggered Grid Layout
*/
enum class NoteSize {
SMALL, // Compact display (< 80 chars or ≤ 4 checklist items)
LARGE; // Full-width display
companion object {
const val SMALL_TEXT_THRESHOLD = 80 // Max characters for compact text note
const val SMALL_CHECKLIST_THRESHOLD = 4 // Max items for compact checklist
}
}
/**
* 🎨 v1.7.0: Determine note size for grid layout optimization
*/
fun Note.getSize(): NoteSize {
return when (noteType) {
NoteType.TEXT -> {
if (content.length < NoteSize.SMALL_TEXT_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
}
NoteType.CHECKLIST -> {
val itemCount = checklistItems?.size ?: 0
if (itemCount <= NoteSize.SMALL_CHECKLIST_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
}
}
}
// Extension für JSON-Escaping
fun String.escapeJson(): String {
return this

View File

@@ -123,6 +123,26 @@ class NotesStorage(private val context: Context) {
saveDeletionTracker(DeletionTracker())
Logger.d(TAG, "🗑️ Deletion tracker cleared")
}
/**
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
* This ensures notes are uploaded to the new server on next sync
*/
fun resetAllSyncStatusToPending(): Int {
val notes = loadAllNotes()
var updatedCount = 0
notes.forEach { note ->
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
saveNote(updatedNote)
updatedCount++
}
}
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount
}
fun getNotesDir(): File = notesDir

View File

@@ -41,7 +41,7 @@ class WebDavSyncService(private val context: Context) {
companion object {
private const val TAG = "WebDavSyncService"
private const val SOCKET_TIMEOUT_MS = 2000
private const val SOCKET_TIMEOUT_MS = 1000 // 🆕 v1.7.0: Reduziert von 2s auf 1s
private const val MAX_FILENAME_LENGTH = 200
private const val ETAG_PREVIEW_LENGTH = 8
private const val CONTENT_PREVIEW_LENGTH = 50
@@ -130,7 +130,14 @@ class WebDavSyncService(private val context: Context) {
return null
}
// Nur wenn WiFi aktiv
// 🔒 v1.7.0: VPN-Detection - Skip WiFi binding when VPN is active
// When VPN is active, traffic should route through VPN, not directly via WiFi
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
Logger.d(TAG, "🔒 VPN detected - using default routing (traffic will go through VPN)")
return null
}
// Nur wenn WiFi aktiv (und kein VPN)
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
Logger.d(TAG, "⚠️ Not on WiFi, using default routing")
return null
@@ -556,6 +563,25 @@ class WebDavSyncService(private val context: Context) {
}
}
/**
* 🆕 v1.7.0: Prüft ob Gerät aktuell im WLAN ist
* Für schnellen Pre-Check VOR dem langsamen Socket-Check
*
* @return true wenn WLAN verbunden, false sonst (mobil oder kein Netzwerk)
*/
fun isOnWiFi(): Boolean {
return try {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as? ConnectivityManager ?: return false
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} catch (e: Exception) {
Logger.e(TAG, "Failed to check WiFi state", e)
false
}
}
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
return@withContext try {
val sardine = getOrCreateSardine() ?: return@withContext SyncResult(

View File

@@ -83,6 +83,11 @@ fun NoteEditorScreen(
var focusNewItemId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// Strings for toast messages (avoid LocalContextGetResourceValueCall lint)
val msgNoteIsEmpty = stringResource(R.string.note_is_empty)
val msgNoteSaved = stringResource(R.string.note_saved)
val msgNoteDeleted = stringResource(R.string.note_deleted)
// v1.5.0: Auto-keyboard support
val keyboardController = LocalSoftwareKeyboardController.current
val titleFocusRequester = remember { FocusRequester() }
@@ -111,9 +116,9 @@ fun NoteEditorScreen(
when (event) {
is NoteEditorEvent.ShowToast -> {
val message = when (event.message) {
ToastMessage.NOTE_IS_EMPTY -> context.getString(R.string.note_is_empty)
ToastMessage.NOTE_SAVED -> context.getString(R.string.note_saved)
ToastMessage.NOTE_DELETED -> context.getString(R.string.note_deleted)
ToastMessage.NOTE_IS_EMPTY -> msgNoteIsEmpty
ToastMessage.NOTE_SAVED -> msgNoteSaved
ToastMessage.NOTE_DELETED -> msgNoteDeleted
}
context.showToast(message)
}

View File

@@ -183,6 +183,9 @@ class ComposeMainActivity : ComponentActivity() {
// This ensures UI reflects current offline mode when returning from Settings
viewModel.refreshOfflineModeState()
// 🎨 v1.7.0: Refresh display mode when returning from Settings
viewModel.refreshDisplayMode()
// Register BroadcastReceiver for Background-Sync
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
LocalBroadcastManager.getInstance(this).registerReceiver(

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
@@ -50,6 +51,7 @@ import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
import dev.dettmer.simplenotes.ui.main.components.EmptyState
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
import dev.dettmer.simplenotes.ui.main.components.NotesList
import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
import kotlinx.coroutines.launch
@@ -82,12 +84,17 @@ fun MainScreen(
// 🌟 v1.6.0: Reactive offline mode state
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
// 🎨 v1.7.0: Display mode (list or grid)
val displayMode by viewModel.displayMode.collectAsState()
// Delete confirmation dialog state
var showBatchDeleteDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// 🎨 v1.7.0: gridState für Staggered Grid Layout
val gridState = rememberLazyStaggeredGridState()
// Compute isSyncing once
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
@@ -116,9 +123,14 @@ fun MainScreen(
}
// Phase 3: Scroll to top when new note created
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
LaunchedEffect(scrollToTop) {
if (scrollToTop) {
listState.animateScrollToItem(0)
if (displayMode == "grid") {
gridState.animateScrollToItem(0)
} else {
listState.animateScrollToItem(0)
}
viewModel.resetScrollToTop()
}
}
@@ -177,22 +189,44 @@ fun MainScreen(
if (notes.isEmpty()) {
EmptyState(modifier = Modifier.weight(1f))
} else {
NotesList(
notes = notes,
showSyncStatus = viewModel.isServerConfigured(),
selectedNotes = selectedNotes,
isSelectionMode = isSelectionMode,
listState = listState,
modifier = Modifier.weight(1f),
onNoteClick = { note -> onOpenNote(note.id) },
onNoteLongPress = { note ->
// Long-press starts selection mode
viewModel.startSelectionMode(note.id)
},
onNoteSelectionToggle = { note ->
viewModel.toggleNoteSelection(note.id)
}
)
// 🎨 v1.7.0: Switch between List and Grid based on display mode
if (displayMode == "grid") {
NotesStaggeredGrid(
notes = notes,
gridState = gridState,
showSyncStatus = viewModel.isServerConfigured(),
selectedNoteIds = selectedNotes,
isSelectionMode = isSelectionMode,
modifier = Modifier.weight(1f),
onNoteClick = { note ->
if (isSelectionMode) {
viewModel.toggleNoteSelection(note.id)
} else {
onOpenNote(note.id)
}
},
onNoteLongClick = { note ->
viewModel.startSelectionMode(note.id)
}
)
} else {
NotesList(
notes = notes,
showSyncStatus = viewModel.isServerConfigured(),
selectedNotes = selectedNotes,
isSelectionMode = isSelectionMode,
listState = listState,
modifier = Modifier.weight(1f),
onNoteClick = { note -> onOpenNote(note.id) },
onNoteLongPress = { note ->
// Long-press starts selection mode
viewModel.startSelectionMode(note.id)
},
onNoteSelectionToggle = { note ->
viewModel.toggleNoteSelection(note.id)
}
)
}
}
}

View File

@@ -11,7 +11,6 @@ import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.SyncConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -83,6 +82,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue$newValue")
}
// ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Mode State
// ═══════════════════════════════════════════════════════════════════════
private val _displayMode = MutableStateFlow(
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
)
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
/**
* Refresh display mode from SharedPreferences
* Called when returning from Settings screen
*/
fun refreshDisplayMode() {
val newValue = prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
_displayMode.value = newValue
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value}$newValue")
}
// ═══════════════════════════════════════════════════════════════════════
// Sync State (derived from SyncStateManager)
// ═══════════════════════════════════════════════════════════════════════
@@ -490,7 +508,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
// 🆕 v1.7.0: WiFi-Only Check (sofort, kein Netzwerk-Wait)
val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
if (wifiOnlySync) {
val syncService = WebDavSyncService(getApplication())
if (!syncService.isOnWiFi()) {
Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi")
SyncStateManager.markError(getString(R.string.sync_wifi_only_hint))
return
}
}
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
if (!SyncStateManager.tryStartSync(source)) {
if (SyncStateManager.isSyncing) {
Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress")
viewModelScope.launch {
_showSnackbar.emit(SnackbarData(
message = getString(R.string.sync_already_running),
actionLabel = "",
onAction = {}
))
}
}
return
}

View File

@@ -72,7 +72,7 @@ fun NoteCard(
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
// 🎨 v1.7.0: Externes Padding entfernt - Grid/Liste steuert Abstände
.then(
if (isSelected) {
Modifier.border(

View File

@@ -0,0 +1,246 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.List
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.CloudDone
import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material.icons.outlined.CloudSync
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.utils.toReadableTime
/**
* 🎨 v1.7.0: Compact Note Card for Grid Layout
*
* COMPACT DESIGN für kleine Notizen:
* - Reduzierter Padding (12dp statt 16dp)
* - Kleinere Icons (24dp statt 32dp)
* - Kompakte Typography (titleSmall)
* - Max 3 Zeilen Preview
* - Optimiert für Grid-Ansicht
*/
@Composable
fun NoteCardCompact(
note: Note,
showSyncStatus: Boolean,
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val context = LocalContext.current
Card(
modifier = modifier
.fillMaxWidth()
.then(
if (isSelected) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp)
)
} else Modifier
)
.pointerInput(note.id, isSelectionMode) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
)
) {
Box {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
// Header row - COMPACT
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Type icon - SMALLER
Box(
modifier = Modifier
.size(24.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (note.noteType == NoteType.TEXT)
Icons.Outlined.Description
else
Icons.AutoMirrored.Outlined.List,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(12.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Title - COMPACT Typography
Text(
text = note.title.ifEmpty { stringResource(R.string.untitled) },
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(6.dp))
// Preview - MAX 3 ZEILEN
Text(
text = when (note.noteType) {
NoteType.TEXT -> note.content
NoteType.CHECKLIST -> {
note.checklistItems
?.joinToString("\n") { item ->
val prefix = if (item.isChecked) "" else ""
"$prefix ${item.text}"
} ?: ""
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(6.dp))
// Bottom row - KOMPAKT
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Timestamp - SMALLER
Text(
text = note.updatedAt.toReadableTime(context),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.weight(1f)
)
// Sync Status - KOMPAKT
if (showSyncStatus) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = when (note.syncStatus) {
SyncStatus.SYNCED -> Icons.Outlined.CloudDone
SyncStatus.PENDING -> Icons.Outlined.CloudSync
SyncStatus.CONFLICT -> Icons.Default.Warning
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
},
contentDescription = null,
tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.outline
},
modifier = Modifier.size(14.dp)
)
}
}
}
// Selection indicator checkbox (top-right)
androidx.compose.animation.AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
) {
Box(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(
if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
)
.border(
width = 2.dp,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.selection_count, 1),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.dp)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,250 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.List
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.CloudDone
import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material.icons.outlined.CloudSync
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteSize
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.models.getSize
import dev.dettmer.simplenotes.utils.toReadableTime
/**
* 🎨 v1.7.0: Unified Note Card for Grid Layout
*
* Einheitliche Card für ALLE Notizen im Grid:
* - Dynamische maxLines basierend auf NoteSize
* - LARGE notes: 6 Zeilen Preview
* - SMALL notes: 3 Zeilen Preview
* - Kein externes Padding - Grid steuert Abstände
* - Optimiert für Pinterest-style dynamisches Layout
*/
@Composable
fun NoteCardGrid(
note: Note,
showSyncStatus: Boolean,
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val context = LocalContext.current
val noteSize = note.getSize()
// Dynamische maxLines basierend auf Größe
val previewMaxLines = if (noteSize == NoteSize.LARGE) 6 else 3
Card(
modifier = Modifier
.fillMaxWidth()
// Kein externes Padding - Grid steuert alles
.then(
if (isSelected) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp)
)
} else Modifier
)
.pointerInput(note.id, isSelectionMode) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
)
) {
Box {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp) // Einheitliches internes Padding
) {
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Type icon
Box(
modifier = Modifier
.size(24.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (note.noteType == NoteType.TEXT)
Icons.Outlined.Description
else
Icons.AutoMirrored.Outlined.List,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(12.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Title
Text(
text = note.title.ifEmpty { stringResource(R.string.untitled) },
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(6.dp))
// Preview - Dynamische Zeilen basierend auf NoteSize
Text(
text = when (note.noteType) {
NoteType.TEXT -> note.content
NoteType.CHECKLIST -> {
note.checklistItems
?.joinToString("\n") { item ->
val prefix = if (item.isChecked) "" else ""
"$prefix ${item.text}"
} ?: ""
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = previewMaxLines, // 🎯 Dynamisch: LARGE=6, SMALL=3
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(6.dp))
// Footer
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = note.updatedAt.toReadableTime(context),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.weight(1f)
)
if (showSyncStatus) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = when (note.syncStatus) {
SyncStatus.SYNCED -> Icons.Outlined.CloudDone
SyncStatus.PENDING -> Icons.Outlined.CloudSync
SyncStatus.CONFLICT -> Icons.Default.Warning
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
},
contentDescription = null,
tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.outline
},
modifier = Modifier.size(14.dp)
)
}
}
}
// Selection indicator checkbox (top-right)
androidx.compose.animation.AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
) {
Box(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(
if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
)
.border(
width = 2.dp,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.selection_count, 1),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.dp)
)
}
}
}
}
}
}

View File

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
@@ -49,6 +50,8 @@ fun NotesList(
showSyncStatus = showSyncStatus,
isSelected = isSelected,
isSelectionMode = isSelectionMode,
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
onClick = {
if (isSelectionMode) {
// In selection mode, tap toggles selection

View File

@@ -0,0 +1,70 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.utils.Constants
/**
* 🎨 v1.7.0: Staggered Grid Layout - OPTIMIERT
*
* Pinterest-style Grid:
* - ALLE Items als SingleLane (halbe Breite)
* - Dynamische Höhe basierend auf NoteSize (LARGE=6 Zeilen, SMALL=3 Zeilen)
* - Keine Lücken mehr durch FullLine-Items
* - Selection mode support
* - Efficient LazyVerticalStaggeredGrid
*/
@Composable
fun NotesStaggeredGrid(
notes: List<Note>,
gridState: LazyStaggeredGridState,
showSyncStatus: Boolean,
selectedNoteIds: Set<String>,
isSelectionMode: Boolean,
modifier: Modifier = Modifier,
onNoteClick: (Note) -> Unit,
onNoteLongClick: (Note) -> Unit
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(Constants.GRID_COLUMNS),
modifier = modifier.fillMaxSize(),
state = gridState,
// 🎨 v1.7.0: Konsistente Abstände - 16dp horizontal wie Liste, mehr Platz für FAB
contentPadding = PaddingValues(
start = 16.dp, // Wie Liste, war 8dp
end = 16.dp,
top = 8.dp,
bottom = 80.dp // Mehr Platz für FAB, war 16dp
),
horizontalArrangement = Arrangement.spacedBy(12.dp), // War 8dp
verticalItemSpacing = 12.dp // War Constants.GRID_SPACING_DP (8dp)
) {
items(
items = notes,
key = { it.id }
// 🎨 v1.7.0: KEIN span mehr - alle Items sind SingleLane (halbe Breite)
) { note ->
val isSelected = selectedNoteIds.contains(note.id)
// 🎉 Einheitliche Card für alle Größen - dynamische maxLines intern
NoteCardGrid(
note = note,
showSyncStatus = showSyncStatus,
isSelected = isSelected,
isSelectionMode = isSelectionMode,
onClick = { onNoteClick(note) },
onLongClick = { onNoteLongClick(note) }
)
}
}
}

View File

@@ -7,6 +7,7 @@ import androidx.navigation.compose.composable
import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.DisplaySettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.LanguageSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
@@ -95,5 +96,13 @@ fun SettingsNavHost(
onBack = { navController.popBackStack() }
)
}
// 🎨 v1.7.0: Display Settings
composable(SettingsRoute.Display.route) {
DisplaySettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
}
}

View File

@@ -13,4 +13,5 @@ sealed class SettingsRoute(val route: String) {
data object Backup : SettingsRoute("settings_backup")
data object About : SettingsRoute("settings_about")
data object Debug : SettingsRoute("settings_debug")
data object Display : SettingsRoute("settings_display") // 🎨 v1.7.0
}

View File

@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.backup.BackupManager
import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
@@ -33,6 +34,7 @@ import java.net.URL
*
* Manages all settings state and actions across the Settings navigation graph.
*/
@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*)
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
companion object {
@@ -42,6 +44,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val backupManager = BackupManager(application)
private val notesStorage = NotesStorage(application) // v1.7.0: For server change detection
// 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection
// This prevents false-positive "server changed" toasts during text input
private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
// ═══════════════════════════════════════════════════════════════════════
// Server Settings State
@@ -154,6 +161,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
)
val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow()
// 🎉 v1.7.0: WiFi-Only Sync Toggle
private val _wifiOnlySync = MutableStateFlow(
prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
)
val wifiOnlySync: StateFlow<Boolean> = _wifiOnlySync.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Markdown Settings State
// ═══════════════════════════════════════════════════════════════════════
@@ -173,6 +186,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
)
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Settings State
// ═══════════════════════════════════════════════════════════════════════
private val _displayMode = MutableStateFlow(
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
)
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// UI State
// ═══════════════════════════════════════════════════════════════════════
@@ -216,41 +238,130 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
/**
* 🌟 v1.6.0: Update only the host part of the server URL
* The protocol prefix is handled separately by updateProtocol()
* 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
* 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
* but WITHOUT server-change detection (detection happens only on screen exit)
*/
fun updateServerHost(host: String) {
_serverHost.value = host
saveServerSettings()
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (host.isEmpty()) "" else prefix + host
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
}
fun updateProtocol(useHttps: Boolean) {
_isHttps.value = useHttps
// 🌟 v1.6.0: Host stays the same, only prefix changes
saveServerSettings()
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
val prefix = if (useHttps) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
}
fun updateUsername(value: String) {
_username.value = value
saveServerSettings()
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
}
fun updatePassword(value: String) {
_password.value = value
saveServerSettings()
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
}
private fun saveServerSettings() {
/**
* 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
* This prevents false "server changed" detection during text input
* 🔧 v1.7.0 Regression Fix: Settings are now saved IMMEDIATELY in update functions.
* This function now ONLY handles server-change detection and sync reset.
*/
fun saveServerSettingsManually() {
// 🌟 v1.6.0: Construct full URL from prefix + host
val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
prefs.edit().apply {
putString(Constants.KEY_SERVER_URL, fullUrl)
putString(Constants.KEY_USERNAME, _username.value)
putString(Constants.KEY_PASSWORD, _password.value)
apply()
// 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
// ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
// This function now ONLY handles server-change detection
// Reset sync status if server actually changed
if (serverChanged) {
viewModelScope.launch {
val count = notesStorage.resetAllSyncStatusToPending()
Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
emitToast(getString(R.string.toast_server_changed_sync_reset, count))
}
// Update confirmed state after reset
confirmedServerUrl = fullUrl
} else {
Logger.d(TAG, "💾 Server settings check complete (no server change detected)")
}
}
/**
* <20> v1.7.0 Hotfix: Improved server change detection
*
* Only returns true if the server URL actually changed in a meaningful way.
* Handles edge cases:
* - First setup (empty → filled) = NOT a change
* - Protocol only (http → https) = NOT a change
* - Server removed (filled → empty) = NOT a change
* - Trailing slashes, case differences = NOT a change
* - Different hostname/port/path = IS a change ✓
*/
private fun isServerReallyChanged(confirmedUrl: String, newUrl: String): Boolean {
// Empty → Non-empty = First setup, NOT a change
if (confirmedUrl.isEmpty() && newUrl.isNotEmpty()) {
Logger.d(TAG, "First server setup detected (no reset needed)")
return false
}
// Both empty = No change
if (confirmedUrl.isEmpty() && newUrl.isEmpty()) {
return false
}
// Non-empty → Empty = Server removed (keep notes local, no reset)
if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) {
Logger.d(TAG, "Server removed (notes stay local, no reset needed)")
return false
}
// Same URL = No change
if (confirmedUrl == newUrl) {
return false
}
// Normalize URLs for comparison (ignore protocol, trailing slash, case)
val normalize = { url: String ->
url.trim()
.removePrefix("http://")
.removePrefix("https://")
.removeSuffix("/")
.lowercase()
}
val confirmedNormalized = normalize(confirmedUrl)
val newNormalized = normalize(newUrl)
// Check if normalized URLs differ
val changed = confirmedNormalized != newNormalized
if (changed) {
Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'")
}
return changed
}
fun testConnection() {
viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking
@@ -412,6 +523,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
Logger.d(TAG, "Trigger Boot: $enabled")
}
/**
* 🎉 v1.7.0: Set WiFi-only sync mode
* When enabled, sync only happens when connected to WiFi
*/
fun setWifiOnlySync(enabled: Boolean) {
_wifiOnlySync.value = enabled
prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply()
Logger.d(TAG, "📡 WiFi-only sync: $enabled")
}
// ═══════════════════════════════════════════════════════════════════════
// Markdown Settings Actions
// ═══════════════════════════════════════════════════════════════════════
@@ -519,11 +640,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// Backup Actions
// ═══════════════════════════════════════════════════════════════════════
fun createBackup(uri: Uri) {
fun createBackup(uri: Uri, password: String? = null) {
viewModelScope.launch {
_isBackupInProgress.value = true
try {
val result = backupManager.createBackup(uri)
val result = backupManager.createBackup(uri, password)
val message = if (result.success) {
getString(R.string.toast_backup_success, result.message ?: "")
} else {
@@ -538,11 +659,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
fun restoreFromFile(uri: Uri, mode: RestoreMode) {
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
viewModelScope.launch {
_isBackupInProgress.value = true
try {
val result = backupManager.restoreBackup(uri, mode)
val result = backupManager.restoreBackup(uri, mode, password)
val message = if (result.success) {
getString(R.string.toast_restore_success, result.importedNotes)
} else {
@@ -557,6 +678,29 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
/**
* 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback
*/
fun checkBackupEncryption(
uri: Uri,
onEncrypted: () -> Unit,
onPlaintext: () -> Unit
) {
viewModelScope.launch {
try {
val isEncrypted = backupManager.isBackupEncrypted(uri)
if (isEncrypted) {
onEncrypted()
} else {
onPlaintext()
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to check encryption status", e)
onPlaintext() // Assume plaintext on error
}
}
}
fun restoreFromServer(mode: RestoreMode) {
viewModelScope.launch {
_isBackupInProgress.value = true
@@ -664,4 +808,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val total: Int,
val isComplete: Boolean = false
)
// ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Mode Functions
// ═══════════════════════════════════════════════════════════════════════
/**
* Set display mode (list or grid)
*/
fun setDisplayMode(mode: String) {
_displayMode.value = mode
prefs.edit().putString(Constants.KEY_DISPLAY_MODE, mode).apply()
Logger.d(TAG, "Display mode changed to: $mode")
}
}

View File

@@ -0,0 +1,180 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
private const val MIN_PASSWORD_LENGTH = 8
/**
* 🔒 v1.7.0: Password input dialog for backup encryption/decryption
*/
@Composable
fun BackupPasswordDialog(
title: String,
onDismiss: () -> Unit,
onConfirm: (password: String) -> Unit,
requireConfirmation: Boolean = true
) {
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var confirmPasswordVisible by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
// Password field
OutlinedTextField(
value = password,
onValueChange = {
password = it
errorMessage = null
},
label = { Text(stringResource(R.string.backup_encryption_password)) },
placeholder = { Text(stringResource(R.string.backup_encryption_password_hint)) },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = null
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = if (requireConfirmation) ImeAction.Next else ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = if (!requireConfirmation) {
{ validateAndConfirm(password, null, onConfirm) { errorMessage = it } }
} else null
),
singleLine = true,
isError = errorMessage != null,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
// Confirm password field (only for encryption, not decryption)
if (requireConfirmation) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
errorMessage = null
},
label = { Text(stringResource(R.string.backup_encryption_confirm)) },
placeholder = { Text(stringResource(R.string.backup_encryption_confirm_hint)) },
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
Icon(
imageVector = if (confirmPasswordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = null
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { validateAndConfirm(password, confirmPassword, onConfirm) { errorMessage = it } }
),
singleLine = true,
isError = errorMessage != null,
modifier = Modifier.fillMaxWidth()
)
}
// Error message
if (errorMessage != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = errorMessage!!,
color = androidx.compose.material3.MaterialTheme.colorScheme.error,
style = androidx.compose.material3.MaterialTheme.typography.bodySmall
)
}
}
},
confirmButton = {
TextButton(
onClick = {
validateAndConfirm(
password,
if (requireConfirmation) confirmPassword else null,
onConfirm
) { errorMessage = it }
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
}
)
}
/**
* Validate password and call onConfirm if valid
*/
private fun validateAndConfirm(
password: String,
confirmPassword: String?,
onConfirm: (String) -> Unit,
onError: (String) -> Unit
) {
when {
password.length < MIN_PASSWORD_LENGTH -> {
onError("Password too short (min. $MIN_PASSWORD_LENGTH characters)")
}
confirmPassword != null && password != confirmPassword -> {
onError("Passwords don't match")
}
else -> {
onConfirm(password)
}
}
}

View File

@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
@@ -34,6 +35,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsOutlinedButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -58,11 +60,25 @@ fun BackupSettingsScreen(
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
// 🔐 v1.7.0: Encryption state
var encryptBackup by remember { mutableStateOf(false) }
var showEncryptionPasswordDialog by remember { mutableStateOf(false) }
var showDecryptionPasswordDialog by remember { mutableStateOf(false) }
var pendingBackupUri by remember { mutableStateOf<Uri?>(null) }
// File picker launchers
val createBackupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { viewModel.createBackup(it) }
uri?.let {
// 🔐 v1.7.0: If encryption enabled, show password dialog first
if (encryptBackup) {
pendingBackupUri = it
showEncryptionPasswordDialog = true
} else {
viewModel.createBackup(it, password = null)
}
}
}
val restoreFileLauncher = rememberLauncherForActivityResult(
@@ -99,6 +115,16 @@ fun BackupSettingsScreen(
Spacer(modifier = Modifier.height(8.dp))
// 🔐 v1.7.0: Encryption toggle
SettingsSwitch(
title = stringResource(R.string.backup_encryption_title),
subtitle = stringResource(R.string.backup_encryption_subtitle),
checked = encryptBackup,
onCheckedChange = { encryptBackup = it }
)
Spacer(modifier = Modifier.height(8.dp))
SettingsButton(
text = stringResource(R.string.backup_create),
onClick = {
@@ -156,6 +182,47 @@ fun BackupSettingsScreen(
}
}
// 🔐 v1.7.0: Encryption password dialog (for backup creation)
if (showEncryptionPasswordDialog) {
BackupPasswordDialog(
title = stringResource(R.string.backup_encryption_title),
onDismiss = {
showEncryptionPasswordDialog = false
pendingBackupUri = null
},
onConfirm = { password ->
showEncryptionPasswordDialog = false
pendingBackupUri?.let { uri ->
viewModel.createBackup(uri, password)
}
pendingBackupUri = null
},
requireConfirmation = true
)
}
// 🔐 v1.7.0: Decryption password dialog (for restore)
if (showDecryptionPasswordDialog) {
BackupPasswordDialog(
title = stringResource(R.string.backup_decryption_required),
onDismiss = {
showDecryptionPasswordDialog = false
pendingRestoreUri = null
},
onConfirm = { password ->
showDecryptionPasswordDialog = false
pendingRestoreUri?.let { uri ->
when (restoreSource) {
RestoreSource.LocalFile -> viewModel.restoreFromFile(uri, selectedRestoreMode, password)
RestoreSource.Server -> { /* Server restore doesn't support encryption */ }
}
}
pendingRestoreUri = null
},
requireConfirmation = false
)
}
// Restore Mode Dialog
if (showRestoreDialog) {
RestoreModeDialog(
@@ -167,7 +234,17 @@ fun BackupSettingsScreen(
when (restoreSource) {
RestoreSource.LocalFile -> {
pendingRestoreUri?.let { uri ->
viewModel.restoreFromFile(uri, selectedRestoreMode)
// 🔐 v1.7.0: Check if backup is encrypted
viewModel.checkBackupEncryption(
uri = uri,
onEncrypted = {
showDecryptionPasswordDialog = true
},
onPlaintext = {
viewModel.restoreFromFile(uri, selectedRestoreMode, password = null)
pendingRestoreUri = null
}
)
}
}
RestoreSource.Server -> {

View File

@@ -82,6 +82,9 @@ fun DebugSettingsScreen(
Spacer(modifier = Modifier.height(8.dp))
// Export Logs Button
val logsSubject = stringResource(R.string.debug_logs_subject)
val logsShareVia = stringResource(R.string.debug_logs_share_via)
SettingsButton(
text = stringResource(R.string.debug_export_logs),
onClick = {
@@ -96,11 +99,11 @@ fun DebugSettingsScreen(
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, logUri)
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.debug_logs_subject))
putExtra(Intent.EXTRA_SUBJECT, logsSubject)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.debug_logs_share_via)))
context.startActivity(Intent.createChooser(shareIntent, logsShareVia))
}
},
modifier = Modifier.padding(horizontal = 16.dp)

View File

@@ -0,0 +1,74 @@
package dev.dettmer.simplenotes.ui.settings.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
/**
* 🎨 v1.7.0: Display Settings Screen
*
* Allows switching between List and Grid view modes.
*/
@Composable
fun DisplaySettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val displayMode by viewModel.displayMode.collectAsState()
SettingsScaffold(
title = stringResource(R.string.display_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
SettingsSectionHeader(text = stringResource(R.string.display_mode_title))
SettingsRadioGroup(
options = listOf(
RadioOption(
value = "list",
title = stringResource(R.string.display_mode_list),
subtitle = null
),
RadioOption(
value = "grid",
title = stringResource(R.string.display_mode_grid),
subtitle = null
)
),
selectedValue = displayMode,
onValueSelected = { viewModel.setDisplayMode(it) }
)
SettingsInfoCard(
text = stringResource(R.string.display_mode_info)
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -56,6 +57,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
* Server configuration settings screen
* v1.5.0: Jetpack Compose Settings Redesign
* v1.6.0: Offline Mode Toggle
* v1.7.0 Hotfix: Save settings on screen exit (not on every keystroke)
*/
@Suppress("LongMethod", "MagicNumber") // Compose UI + Color hex values
@Composable
@@ -74,6 +76,14 @@ fun ServerSettingsScreen(
var passwordVisible by remember { mutableStateOf(false) }
// 🔧 v1.7.0 Hotfix: Save server settings when leaving this screen
// This prevents false "server changed" detection during text input
DisposableEffect(Unit) {
onDispose {
viewModel.saveServerSettingsManually()
}
}
// Check server status on load (only if not in offline mode)
LaunchedEffect(offlineMode) {
if (!offlineMode) {

View File

@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Sync
@@ -90,6 +91,22 @@ fun SettingsMainScreen(
)
}
// 🎨 v1.7.0: Display Settings
item {
val displayMode by viewModel.displayMode.collectAsState()
val displaySubtitle = when (displayMode) {
"grid" -> stringResource(R.string.display_mode_grid)
else -> stringResource(R.string.display_mode_list)
}
SettingsCard(
icon = Icons.Default.GridView,
title = stringResource(R.string.display_settings_title),
subtitle = displaySubtitle,
onClick = { onNavigate(SettingsRoute.Display) }
)
}
// Server-Einstellungen
item {
// 🌟 v1.6.0: Check if server is configured (host is not empty)

View File

@@ -50,6 +50,9 @@ fun SyncSettingsScreen(
val triggerBoot by viewModel.triggerBoot.collectAsState()
val syncInterval by viewModel.syncInterval.collectAsState()
// 🆕 v1.7.0: WiFi-only sync
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
// Check if server is configured
val isServerConfigured = viewModel.isServerConfigured()
@@ -108,6 +111,16 @@ fun SyncSettingsScreen(
enabled = isServerConfigured
)
// 🆕 v1.7.0: WiFi-Only Sync Toggle
SettingsSwitch(
title = stringResource(R.string.sync_wifi_only_title),
subtitle = stringResource(R.string.sync_wifi_only_subtitle),
checked = wifiOnlySync,
onCheckedChange = { viewModel.setWifiOnlySync(it) },
icon = Icons.Default.Wifi,
enabled = isServerConfigured
)
SettingsDivider()
// ═══════════════════════════════════════════════════════════════

View File

@@ -32,6 +32,10 @@ object Constants {
// 🔥 v1.6.0: Offline Mode Toggle
const val KEY_OFFLINE_MODE = "offline_mode_enabled"
// 🔥 v1.7.0: WiFi-Only Sync Toggle
const val KEY_WIFI_ONLY_SYNC = "wifi_only_sync_enabled"
const val DEFAULT_WIFI_ONLY_SYNC = false // Standardmäßig auch mobil syncen
// 🔥 v1.6.0: Configurable Sync Triggers
const val KEY_SYNC_TRIGGER_ON_SAVE = "sync_trigger_on_save"
const val KEY_SYNC_TRIGGER_ON_RESUME = "sync_trigger_on_resume"
@@ -57,4 +61,10 @@ object Constants {
// Notifications
const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel"
const val NOTIFICATION_ID = 1001
// 🎨 v1.7.0: Staggered Grid Layout
const val KEY_DISPLAY_MODE = "display_mode" // "list" or "grid"
const val DEFAULT_DISPLAY_MODE = "list"
const val GRID_COLUMNS = 2
const val GRID_SPACING_DP = 8
}

View File

@@ -23,6 +23,7 @@
<!-- ============================= -->
<!-- EMPTY STATE -->
<!-- ============================= -->
<string name="empty_state_emoji">📝</string>
<string name="empty_state_title">Noch keine Notizen</string>
<string name="empty_state_message">Tippe + um eine neue Notiz zu erstellen</string>
@@ -196,11 +197,11 @@
<string name="sync_interval_section">Sync-Intervall</string>
<string name="sync_interval_info">Legt fest, wie oft die App im Hintergrund synchronisiert. Kürzere Intervalle bedeuten aktuellere Daten, verbrauchen aber etwas mehr Akku.\n\n⏱ Hinweis: Wenn dein Smartphone im Standby ist, kann Android die Synchronisation verzögern (bis zu 60 Min.), um Akku zu sparen. Das ist normal und betrifft alle Hintergrund-Apps.</string>
<string name="sync_interval_15min_title">⚡ Alle 15 Minuten</string>
<string name="sync_interval_15min_subtitle">Schnellste Synchronisation • ~0.8% Akku/Tag (~23 mAh)</string>
<string name="sync_interval_15min_subtitle">Schnellste Synchronisation • ~0.8%% Akku/Tag (~23 mAh)</string>
<string name="sync_interval_30min_title">✓ Alle 30 Minuten (Empfohlen)</string>
<string name="sync_interval_30min_subtitle">Ausgewogenes Verhältnis • ~0.4% Akku/Tag (~12 mAh)</string>
<string name="sync_interval_30min_subtitle">Ausgewogenes Verhältnis • ~0.4%% Akku/Tag (~12 mAh)</string>
<string name="sync_interval_60min_title">🔋 Alle 60 Minuten</string>
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2% Akku/Tag (~6 mAh geschätzt)</string>
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2%% Akku/Tag (~6 mAh geschätzt)</string>
<!-- Legacy -->
<string name="auto_sync_info"> Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert bei jeder WiFi-Verbindung\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</string>
@@ -224,6 +225,11 @@
<string name="sync_trigger_boot_title">Nach Gerät-Neustart</string>
<string name="sync_trigger_boot_subtitle">Startet Hintergrund-Sync nach Reboot</string>
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
<string name="sync_wifi_only_title">Sync nur im WLAN</string>
<string name="sync_wifi_only_subtitle">Sync wird nur durchgeführt wenn WLAN verbunden ist. Spart mobiles Datenvolumen und verhindert lange Wartezeit.</string>
<string name="sync_wifi_only_hint">Sync nur im WLAN möglich</string>
<string name="sync_manual_hint">Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfügbar.</string>
<string name="sync_manual_hint_disabled">Sync ist im Offline-Modus nicht verfügbar.</string>
@@ -253,6 +259,20 @@
<string name="backup_local_section">Lokales Backup</string>
<string name="backup_create">💾 Backup erstellen</string>
<string name="backup_restore_file">📂 Aus Datei wiederherstellen</string>
<!-- 🔐 v1.7.0: Verschlüsselung -->
<string name="backup_encryption_title">Backup verschlüsseln</string>
<string name="backup_encryption_subtitle">Schütze deine Backup-Datei mit Passwort</string>
<string name="backup_encryption_password">Passwort</string>
<string name="backup_encryption_password_hint">Passwort eingeben (min. 8 Zeichen)</string>
<string name="backup_encryption_confirm">Passwort bestätigen</string>
<string name="backup_encryption_confirm_hint">Passwort erneut eingeben</string>
<string name="backup_encryption_error_mismatch">Passwörter stimmen nicht überein</string>
<string name="backup_encryption_error_too_short">Passwort zu kurz (min. 8 Zeichen)</string>
<string name="backup_decryption_required">🔒 Verschlüsseltes Backup</string>
<string name="backup_decryption_password">Passwort zum Entschlüsseln eingeben</string>
<string name="backup_decryption_error">❌ Entschlüsselung fehlgeschlagen. Falsches Passwort?</string>
<string name="backup_server_section">Server-Backup</string>
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
@@ -308,6 +328,15 @@
<string name="language_info"> Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</string>
<string name="language_changed_restart">Sprache geändert. Neustart…</string>
<!-- ============================= -->
<!-- SETTINGS - DISPLAY -->
<!-- ============================= -->
<string name="display_settings_title">Anzeige</string>
<string name="display_mode_title">Notizen-Ansicht</string>
<string name="display_mode_list">📋 Listen-Ansicht</string>
<string name="display_mode_grid">🎨 Raster-Ansicht</string>
<string name="display_mode_info">Die Raster-Ansicht zeigt Notizen im Pinterest-Stil. Kurze Notizen erscheinen nebeneinander, lange Notizen nehmen die volle Breite ein.</string>
<!-- ============================= -->
<!-- SETTINGS - ABOUT -->
<!-- ============================= -->
@@ -357,6 +386,8 @@
<string name="toast_logs_deleted">🗑️ Logs gelöscht</string>
<string name="toast_no_logs_to_delete">📭 Keine Logs zum Löschen</string>
<string name="toast_logs_delete_error">❌ Fehler beim Löschen: %s</string>
<!-- 🔄 v1.7.0: Server change notification -->
<string name="toast_server_changed_sync_reset">🔄 Server geändert. %d Notizen werden beim nächsten Sync hochgeladen.</string>
<string name="toast_link_error">❌ Fehler beim Öffnen des Links</string>
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>

View File

@@ -197,11 +197,11 @@
<string name="sync_interval_section">Sync Interval</string>
<string name="sync_interval_info">Determines how often the app syncs in the background. Shorter intervals mean more up-to-date data, but use slightly more battery.\n\n⏱ Note: When your phone is in standby, Android may delay syncs (up to 60 min) to save battery. This is normal and affects all background apps.</string>
<string name="sync_interval_15min_title">⚡ Every 15 minutes</string>
<string name="sync_interval_15min_subtitle">Fastest sync • ~0.8% battery/day (~23 mAh)</string>
<string name="sync_interval_15min_subtitle">Fastest sync • ~0.8%% battery/day (~23 mAh)</string>
<string name="sync_interval_30min_title">✓ Every 30 minutes (Recommended)</string>
<string name="sync_interval_30min_subtitle">Balanced ratio • ~0.4% battery/day (~12 mAh)</string>
<string name="sync_interval_30min_subtitle">Balanced ratio • ~0.4%% battery/day (~12 mAh)</string>
<string name="sync_interval_60min_title">🔋 Every 60 minutes</string>
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2% battery/day (~6 mAh est.)</string>
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2%% battery/day (~6 mAh est.)</string>
<!-- Legacy -->
<string name="auto_sync_info"> Auto-Sync:\n\n• Checks every 30 min if server is reachable\n• Works on any WiFi connection\n• Runs in background\n• Minimal battery usage (~0.4%%/day)</string>
@@ -225,6 +225,11 @@
<string name="sync_trigger_boot_title">After Device Restart</string>
<string name="sync_trigger_boot_subtitle">Starts background sync after reboot</string>
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
<string name="sync_wifi_only_title">WiFi-only sync</string>
<string name="sync_wifi_only_subtitle">Sync only when connected to WiFi. Saves mobile data and prevents long wait times.</string>
<string name="sync_wifi_only_hint">Sync only works when connected to WiFi</string>
<string name="sync_manual_hint">Manual sync (toolbar/pull-to-refresh) is also available.</string>
<string name="sync_manual_hint_disabled">Sync is not available in offline mode.</string>
@@ -254,6 +259,20 @@
<string name="backup_local_section">Local Backup</string>
<string name="backup_create">💾 Create Backup</string>
<string name="backup_restore_file">📂 Restore from File</string>
<!-- 🔐 v1.7.0: Encryption -->
<string name="backup_encryption_title">Encrypt Backup</string>
<string name="backup_encryption_subtitle">Password-protect your backup file</string>
<string name="backup_encryption_password">Password</string>
<string name="backup_encryption_password_hint">Enter password (min. 8 characters)</string>
<string name="backup_encryption_confirm">Confirm Password</string>
<string name="backup_encryption_confirm_hint">Re-enter password</string>
<string name="backup_encryption_error_mismatch">Passwords don\'t match</string>
<string name="backup_encryption_error_too_short">Password too short (min. 8 characters)</string>
<string name="backup_decryption_required">🔒 Encrypted Backup</string>
<string name="backup_decryption_password">Enter password to decrypt</string>
<string name="backup_decryption_error">❌ Decryption failed. Wrong password?</string>
<string name="backup_server_section">Server Backup</string>
<string name="backup_restore_server">☁️ Restore from Server</string>
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
@@ -309,6 +328,15 @@
<string name="language_info"> Choose your preferred language. The app will restart to apply the change.</string>
<string name="language_changed_restart">Language changed. Restarting…</string>
<!-- ============================= -->
<!-- SETTINGS - DISPLAY -->
<!-- ============================= -->
<string name="display_settings_title">Display</string>
<string name="display_mode_title">Note Display Mode</string>
<string name="display_mode_list">📋 List View</string>
<string name="display_mode_grid">🎨 Grid View</string>
<string name="display_mode_info">Grid view shows notes in a staggered Pinterest-style layout. Small notes appear side-by-side, large notes take full width.</string>
<!-- ============================= -->
<!-- SETTINGS - ABOUT -->
<!-- ============================= -->
@@ -358,6 +386,8 @@
<string name="toast_logs_deleted">🗑️ Logs deleted</string>
<string name="toast_no_logs_to_delete">📭 No logs to delete</string>
<string name="toast_logs_delete_error">❌ Error deleting: %s</string>
<!-- 🔄 v1.7.0: Server change notification -->
<string name="toast_server_changed_sync_reset">🔄 Server changed. %d notes will be uploaded on next sync.</string>
<string name="toast_link_error">❌ Error opening link</string>
<string name="toast_file_logging_enabled">📝 File logging enabled</string>
<string name="toast_file_logging_disabled">📝 File logging disabled</string>

View File

@@ -11,6 +11,8 @@
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<!-- 🔐 v1.7.0: Trust user-installed CA certificates for self-signed SSL support -->
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -0,0 +1,172 @@
package dev.dettmer.simplenotes.models
import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_CHECKLIST_THRESHOLD
import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_TEXT_THRESHOLD
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* 🎨 v1.7.0: Tests for Note Size Classification (Staggered Grid Layout)
*/
class NoteSizeTest {
@Test
fun `text note with less than 80 chars is SMALL`() {
val note = Note(
id = "test1",
title = "Test",
content = "Short content", // 13 chars
deviceId = "test-device",
noteType = NoteType.TEXT
)
assertEquals(NoteSize.SMALL, note.getSize())
}
@Test
fun `text note with exactly 79 chars is SMALL`() {
val content = "x".repeat(79) // Exactly threshold - 1
val note = Note(
id = "test2",
title = "Test",
content = content,
deviceId = "test-device",
noteType = NoteType.TEXT
)
assertEquals(NoteSize.SMALL, note.getSize())
}
@Test
fun `text note with exactly 80 chars is LARGE`() {
val content = "x".repeat(SMALL_TEXT_THRESHOLD) // Exactly at threshold
val note = Note(
id = "test3",
title = "Test",
content = content,
deviceId = "test-device",
noteType = NoteType.TEXT
)
assertEquals(NoteSize.LARGE, note.getSize())
}
@Test
fun `text note with more than 80 chars is LARGE`() {
val content = "This is a long note with more than 80 characters. " +
"It should be classified as LARGE for grid layout display."
val note = Note(
id = "test4",
title = "Test",
content = content,
deviceId = "test-device",
noteType = NoteType.TEXT
)
assertEquals(NoteSize.LARGE, note.getSize())
}
@Test
fun `checklist with 1 item is SMALL`() {
val note = Note(
id = "test5",
title = "Shopping",
content = "",
deviceId = "test-device",
noteType = NoteType.CHECKLIST,
checklistItems = listOf(
ChecklistItem("id1", "Milk", false)
)
)
assertEquals(NoteSize.SMALL, note.getSize())
}
@Test
fun `checklist with 4 items is SMALL`() {
val note = Note(
id = "test6",
title = "Shopping",
content = "",
deviceId = "test-device",
noteType = NoteType.CHECKLIST,
checklistItems = listOf(
ChecklistItem("id1", "Milk", false),
ChecklistItem("id2", "Bread", false),
ChecklistItem("id3", "Eggs", false),
ChecklistItem("id4", "Butter", false)
)
)
assertEquals(NoteSize.SMALL, note.getSize())
}
@Test
fun `checklist with 5 items is LARGE`() {
val note = Note(
id = "test7",
title = "Shopping",
content = "",
deviceId = "test-device",
noteType = NoteType.CHECKLIST,
checklistItems = listOf(
ChecklistItem("id1", "Milk", false),
ChecklistItem("id2", "Bread", false),
ChecklistItem("id3", "Eggs", false),
ChecklistItem("id4", "Butter", false),
ChecklistItem("id5", "Cheese", false) // 5th item -> LARGE
)
)
assertEquals(NoteSize.LARGE, note.getSize())
}
@Test
fun `checklist with many items is LARGE`() {
val items = (1..10).map { ChecklistItem("id$it", "Item $it", false) }
val note = Note(
id = "test8",
title = "Long List",
content = "",
deviceId = "test-device",
noteType = NoteType.CHECKLIST,
checklistItems = items
)
assertEquals(NoteSize.LARGE, note.getSize())
}
@Test
fun `empty checklist is SMALL`() {
val note = Note(
id = "test9",
title = "Empty",
content = "",
deviceId = "test-device",
noteType = NoteType.CHECKLIST,
checklistItems = emptyList()
)
assertEquals(NoteSize.SMALL, note.getSize())
}
@Test
fun `checklist with null items is SMALL`() {
val note = Note(
id = "test10",
title = "Null Items",
content = "",
deviceId = "test-device",
noteType = NoteType.CHECKLIST,
checklistItems = null
)
assertEquals(NoteSize.SMALL, note.getSize())
}
@Test
fun `constants have expected values`() {
assertEquals(80, SMALL_TEXT_THRESHOLD)
assertEquals(4, SMALL_CHECKLIST_THRESHOLD)
}
}

View File

@@ -0,0 +1,205 @@
package dev.dettmer.simplenotes.utils
import dev.dettmer.simplenotes.backup.EncryptionException
import dev.dettmer.simplenotes.backup.EncryptionManager
import org.junit.Assert.*
import org.junit.Test
import java.security.SecureRandom
import kotlin.text.Charsets.UTF_8
/**
* 🔒 v1.7.0: Tests for Local Backup Encryption
*/
class EncryptionManagerTest {
private val encryptionManager = EncryptionManager()
@Test
fun `encrypt and decrypt roundtrip preserves data`() {
val originalData = "This is a test backup with UTF-8: äöü 🔒".toByteArray(UTF_8)
val password = "TestPassword123"
val encrypted = encryptionManager.encrypt(originalData, password)
val decrypted = encryptionManager.decrypt(encrypted, password)
assertArrayEquals(originalData, decrypted)
}
@Test
fun `encrypted data has correct header format`() {
val data = "Test data".toByteArray(UTF_8)
val password = "password123"
val encrypted = encryptionManager.encrypt(data, password)
// Check magic bytes "SNE1"
val magic = encrypted.copyOfRange(0, 4)
assertArrayEquals("SNE1".toByteArray(UTF_8), magic)
// Check version (1 byte = 0x01)
assertEquals(1, encrypted[4].toInt())
// Check minimum size: magic(4) + version(1) + salt(32) + iv(12) + ciphertext + tag(16)
assertTrue("Encrypted data too small: ${encrypted.size}", encrypted.size >= 4 + 1 + 32 + 12 + 16)
}
@Test
fun `isEncrypted returns true for encrypted data`() {
val data = "Test".toByteArray(UTF_8)
val encrypted = encryptionManager.encrypt(data, "password")
assertTrue(encryptionManager.isEncrypted(encrypted))
}
@Test
fun `isEncrypted returns false for plaintext data`() {
val plaintext = "This is not encrypted".toByteArray(UTF_8)
assertFalse(encryptionManager.isEncrypted(plaintext))
}
@Test
fun `isEncrypted returns false for short data`() {
val shortData = "SNE".toByteArray(UTF_8) // Less than 4 bytes
assertFalse(encryptionManager.isEncrypted(shortData))
}
@Test
fun `isEncrypted returns false for wrong magic bytes`() {
val wrongMagic = "FAKE1234567890".toByteArray(UTF_8)
assertFalse(encryptionManager.isEncrypted(wrongMagic))
}
@Test
fun `decrypt with wrong password throws EncryptionException`() {
val data = "Sensitive data".toByteArray(UTF_8)
val correctPassword = "correct123"
val wrongPassword = "wrong123"
val encrypted = encryptionManager.encrypt(data, correctPassword)
val exception = assertThrows(EncryptionException::class.java) {
encryptionManager.decrypt(encrypted, wrongPassword)
}
assertTrue(exception.message?.contains("Decryption failed") == true)
}
@Test
fun `decrypt corrupted data throws EncryptionException`() {
val data = "Test".toByteArray(UTF_8)
val encrypted = encryptionManager.encrypt(data, "password")
// Corrupt the ciphertext (skip header: 4 + 1 + 32 + 12 = 49 bytes)
val corrupted = encrypted.copyOf()
if (corrupted.size > 50) {
corrupted[50] = (corrupted[50] + 1).toByte() // Flip one bit
}
assertThrows(EncryptionException::class.java) {
encryptionManager.decrypt(corrupted, "password")
}
}
@Test
fun `decrypt data with invalid header throws EncryptionException`() {
val invalidData = "This is not encrypted at all".toByteArray(UTF_8)
assertThrows(EncryptionException::class.java) {
encryptionManager.decrypt(invalidData, "password")
}
}
@Test
fun `decrypt truncated data throws EncryptionException`() {
val data = "Test".toByteArray(UTF_8)
val encrypted = encryptionManager.encrypt(data, "password")
// Truncate to only header
val truncated = encrypted.copyOfRange(0, 20)
assertThrows(EncryptionException::class.java) {
encryptionManager.decrypt(truncated, "password")
}
}
@Test
fun `encrypt with different passwords produces different ciphertexts`() {
val data = "Same data".toByteArray(UTF_8)
val encrypted1 = encryptionManager.encrypt(data, "password1")
val encrypted2 = encryptionManager.encrypt(data, "password2")
// Different passwords should produce different ciphertexts
assertFalse(encrypted1.contentEquals(encrypted2))
}
@Test
fun `encrypt same data twice produces different ciphertexts (different IV)`() {
val data = "Same data".toByteArray(UTF_8)
val password = "same-password"
val encrypted1 = encryptionManager.encrypt(data, password)
val encrypted2 = encryptionManager.encrypt(data, password)
// Different IVs should produce different ciphertexts
assertFalse(encrypted1.contentEquals(encrypted2))
// But both should decrypt to same original data
val decrypted1 = encryptionManager.decrypt(encrypted1, password)
val decrypted2 = encryptionManager.decrypt(encrypted2, password)
assertArrayEquals(decrypted1, decrypted2)
}
@Test
fun `encrypt large data (1MB) succeeds`() {
val random = SecureRandom()
val largeData = ByteArray(1024 * 1024) // 1 MB
random.nextBytes(largeData)
val password = "password123"
val encrypted = encryptionManager.encrypt(largeData, password)
val decrypted = encryptionManager.decrypt(encrypted, password)
assertArrayEquals(largeData, decrypted)
}
@Test
fun `encrypt empty data succeeds`() {
val emptyData = ByteArray(0)
val password = "password"
val encrypted = encryptionManager.encrypt(emptyData, password)
val decrypted = encryptionManager.decrypt(encrypted, password)
assertArrayEquals(emptyData, decrypted)
}
@Test
fun `encrypt with empty password succeeds but is unsafe`() {
val data = "Test".toByteArray(UTF_8)
// Crypto library accepts empty passwords (UI prevents this with validation)
val encrypted = encryptionManager.encrypt(data, "")
val decrypted = encryptionManager.decrypt(encrypted, "")
assertArrayEquals(data, decrypted)
assertTrue("Empty password should still produce encrypted data", encrypted.size > data.size)
}
@Test
fun `decrypt with unsupported version throws EncryptionException`() {
val data = "Test".toByteArray(UTF_8)
val encrypted = encryptionManager.encrypt(data, "password")
// Change version byte to unsupported value (99)
val invalidVersion = encrypted.copyOf()
invalidVersion[4] = 99.toByte()
assertThrows(EncryptionException::class.java) {
encryptionManager.decrypt(invalidVersion, "password")
}
}
}