debug: v1.7.0 Features - Grid Layout, WiFi-only Sync, VPN Support
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user