2-Migrate-Persistence-Layer-from-JSON-Files-to-Room-Database #4

Merged
hmalik144 merged 3 commits from 2-Migrate-Persistence-Layer-from-JSON-Files-to-Room-Database into main 2026-04-09 19:14:04 +01:00
20 changed files with 1082 additions and 1760 deletions
Showing only changes of commit f0ae34cdaa - Show all commits

View File

@@ -5,18 +5,18 @@
<!-- Network & Sync Permissions --> <!-- Network & Sync Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Notifications --> <!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Battery Optimization (for WorkManager background sync) --> <!-- Battery Optimization (for WorkManager background sync) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- v1.7.1: Foreground Service for Expedited Work (Android 9-11) --> <!-- v1.7.1: Foreground Service for Expedited Work (Android 9-11) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- v1.7.1: Foreground Service Type for Android 10+ --> <!-- v1.7.1: Foreground Service Type for Android 10+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! --> <!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -44,12 +44,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Legacy MainActivity (XML-based) - kept for reference -->
<activity
android:name=".MainActivity"
android:exported="false"
android:theme="@style/Theme.SimpleNotes" />
<!-- Editor Activity (Legacy - XML-based) --> <!-- Editor Activity (Legacy - XML-based) -->
<activity <activity
android:name=".NoteEditorActivity" android:name=".NoteEditorActivity"
@@ -125,4 +119,4 @@
</application> </application>
</manifest> </manifest>

View File

@@ -1,854 +0,0 @@
@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
package dev.dettmer.simplenotes
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import dev.dettmer.simplenotes.utils.Logger
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.color.DynamicColors
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.card.MaterialCardView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.dettmer.simplenotes.adapters.NotesAdapter
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast
import dev.dettmer.simplenotes.utils.Constants
import android.widget.TextView
import android.widget.CheckBox
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.SyncStateManager
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import android.view.View
import android.widget.LinearLayout
import android.view.Gravity
import android.widget.PopupMenu
import dev.dettmer.simplenotes.models.NoteType
/**
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
* Ersetzt durch ComposeMainActivity
*/
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
class MainActivity : AppCompatActivity() {
private lateinit var recyclerViewNotes: RecyclerView
private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
// 🔄 v1.3.1: Sync Status Banner
private lateinit var syncStatusBanner: LinearLayout
private lateinit var syncStatusText: TextView
private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) }
// Menu reference for sync button state
private var optionsMenu: Menu? = null
// Track pending deletions to prevent flicker when notes reload
private val pendingDeletions = mutableSetOf<String>()
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
companion object {
private const val TAG = "MainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
private const val REQUEST_SETTINGS = 1002
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
private const val SYNC_COMPLETED_DELAY_MS = 1500L
private const val ERROR_DISPLAY_DELAY_MS = 3000L
}
/**
* BroadcastReceiver für Background-Sync Completion (Periodic Sync)
*/
private val syncCompletedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val success = intent?.getBooleanExtra("success", false) ?: false
val count = intent?.getIntExtra("count", 0) ?: 0
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
// UI refresh
if (success && count > 0) {
loadNotes()
Logger.d(TAG, "🔄 Notes reloaded after background sync")
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
// Install Splash Screen (Android 12+)
installSplashScreen()
super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this)
setContentView(R.layout.activity_main)
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
Logger.init(this)
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
Logger.setFileLoggingEnabled(true)
}
// Alte Sync-Notifications beim App-Start löschen
NotificationHelper.clearSyncNotifications(this)
// Permission für Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission()
}
// 🌍 v1.7.2: Debug Locale für Fehlersuche
logLocaleInfo()
findViews()
setupToolbar()
setupRecyclerView()
setupFab()
// v1.4.1: Migrate checklists for backwards compatibility
migrateChecklistsForBackwardsCompat()
loadNotes()
// 🔄 v1.3.1: Observe sync state for UI updates
setupSyncStateObserver()
}
/**
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
*/
private fun setupSyncStateObserver() {
SyncStateManager.syncStatus.observe(this) { status ->
when (status.state) {
SyncStateManager.SyncState.SYNCING -> {
// Disable sync controls
setSyncControlsEnabled(false)
// 🔄 v1.3.1: Show sync status banner (ersetzt SwipeRefresh-Animation)
syncStatusText.text = getString(R.string.sync_status_syncing)
syncStatusBanner.visibility = View.VISIBLE
}
SyncStateManager.SyncState.COMPLETED -> {
// Re-enable sync controls
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
// Show completed briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
lifecycleScope.launch {
kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
syncStatusBanner.visibility = View.GONE
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.ERROR -> {
// Re-enable sync controls
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
// Show error briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
lifecycleScope.launch {
kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
syncStatusBanner.visibility = View.GONE
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.IDLE -> {
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
syncStatusBanner.visibility = View.GONE
}
// v1.5.0: Silent-Sync - Banner nicht anzeigen, aber Sync-Controls deaktivieren
SyncStateManager.SyncState.SYNCING_SILENT -> {
setSyncControlsEnabled(false)
// Kein Banner anzeigen bei Silent-Sync (z.B. onResume Auto-Sync)
}
}
}
}
/**
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
*/
private fun setSyncControlsEnabled(enabled: Boolean) {
// Menu Sync-Button
optionsMenu?.findItem(R.id.action_sync)?.isEnabled = enabled
// SwipeRefresh
swipeRefreshLayout.isEnabled = enabled
}
override fun onResume() {
super.onResume()
Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers")
// Register BroadcastReceiver für Background-Sync
LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
)
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
// Reload notes (scroll to top wird in loadNotes() gemacht)
loadNotes()
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
triggerAutoSync("onResume")
}
/**
* Automatischer Sync (onResume)
* - Nutzt WiFi-gebundenen Socket (VPN Fix!)
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
*
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
*/
private fun triggerAutoSync(source: String = "unknown") {
// Throttling: Max 1 Sync pro Minute
if (!canTriggerAutoSync()) {
return
}
// 🔄 v1.3.1: Check if sync already running
// v1.5.0: silent=true - kein Banner bei Auto-Sync
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return
}
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
lifecycleScope.launch {
try {
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset()
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset()
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
// Feedback abhängig von Source
if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
// onResume: Nur Success-Toast
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
loadNotes()
} else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted()
} else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
// Kein Toast - App ist im Hintergrund
}
} catch (e: Exception) {
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
SyncStateManager.markError(e.message)
// Kein Toast - App ist im Hintergrund
}
}
}
/**
* Prüft ob Auto-Sync getriggert werden darf (Throttling)
*/
private fun canTriggerAutoSync(): Boolean {
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastSyncTime
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
return false
}
return true
}
override fun onPause() {
super.onPause()
// Unregister BroadcastReceiver
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
}
private fun findViews() {
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
// 🔄 v1.3.1: Sync Status Banner
syncStatusBanner = findViewById(R.id.syncStatusBanner)
syncStatusText = findViewById(R.id.syncStatusText)
}
private fun setupToolbar() {
setSupportActionBar(toolbar)
}
private fun setupRecyclerView() {
adapter = NotesAdapter { note ->
openNoteEditor(note.id)
}
recyclerViewNotes.adapter = adapter
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
// 🔥 v1.1.2: Setup Pull-to-Refresh
setupPullToRefresh()
// Setup Swipe-to-Delete
setupSwipeToDelete()
}
/**
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
*/
private fun setupPullToRefresh() {
swipeRefreshLayout.setOnRefreshListener {
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
swipeRefreshLayout.isRefreshing = false
return@setOnRefreshListener
}
lifecycleScope.launch {
try {
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) {
showToast("⚠️ Server noch nicht konfiguriert")
SyncStateManager.reset()
return@launch
}
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
return@launch
}
// Check if server is reachable
if (!syncService.isServerReachable()) {
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
// Perform sync
val result = syncService.syncNotes()
if (result.isSuccess) {
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes()
} else {
SyncStateManager.markError(result.errorMessage)
}
} catch (e: Exception) {
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
SyncStateManager.markError(e.message)
}
}
}
// Set Material 3 color scheme
swipeRefreshLayout.setColorSchemeResources(
com.google.android.material.R.color.material_dynamic_primary50
)
}
private fun setupSwipeToDelete() {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, // No drag
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT // Swipe left or right
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.bindingAdapterPosition
val swipedNote = adapter.currentList[position]
// Store original list BEFORE removing note
val originalList = adapter.currentList.toList()
// Remove from list for visual feedback (NOT from storage yet!)
val listWithoutNote = originalList.toMutableList().apply {
removeAt(position)
}
adapter.submitList(listWithoutNote)
// Show dialog with ability to restore
showServerDeletionDialog(swipedNote, originalList)
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
// Require 80% swipe to trigger
return 0.8f
}
})
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
}
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
if (alwaysDeleteFromServer) {
// Auto-delete from server without asking
deleteNoteLocally(note, deleteFromServer = true)
return
}
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.legacy_delete_dialog_title))
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
.setView(dialogView)
.setNeutralButton(getString(R.string.cancel)) { _, _ ->
// RESTORE: Re-submit original list (note is NOT deleted from storage)
adapter.submitList(originalList)
}
.setOnCancelListener {
// User pressed back - also restore
adapter.submitList(originalList)
}
.setPositiveButton("Nur lokal") { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
}
// NOW actually delete from storage
deleteNoteLocally(note, deleteFromServer = false)
}
.setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
}
deleteNoteLocally(note, deleteFromServer = true)
}
.setCancelable(true)
.show()
}
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
// Track pending deletion to prevent flicker
pendingDeletions.add(note.id)
// Delete from storage
storage.deleteNote(note.id)
// Reload to reflect changes
loadNotes()
// Show Snackbar with UNDO option
val message = if (deleteFromServer) {
getString(R.string.legacy_delete_with_server, note.title)
} else {
getString(R.string.legacy_delete_local_only, note.title)
}
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction(getString(R.string.snackbar_undo)) {
// UNDO: Restore note
storage.saveNote(note)
pendingDeletions.remove(note.id)
loadNotes()
}
.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO
pendingDeletions.remove(note.id)
// Delete from server if requested
if (deleteFromServer) {
lifecycleScope.launch {
try {
val webdavService = WebDavSyncService(this@MainActivity)
val success = webdavService.deleteNoteFromServer(note.id)
if (success) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
getString(R.string.snackbar_deleted_from_server),
Toast.LENGTH_SHORT
).show()
}
} else {
runOnUiThread {
Toast.makeText(
this@MainActivity,
getString(R.string.snackbar_server_delete_failed),
Toast.LENGTH_LONG
).show()
}
}
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Fehler: ${e.message}",
Toast.LENGTH_LONG
).show()
}
}
}
}
}
}
}).show()
}
/**
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
*/
private fun setupFab() {
fabAddNote.setOnClickListener { view ->
showNoteTypePopup(view)
}
}
/**
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
*/
private fun showNoteTypePopup(anchor: View) {
val popupMenu = PopupMenu(this, anchor, Gravity.END)
popupMenu.inflate(R.menu.menu_fab_note_types)
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
try {
val fields = popupMenu.javaClass.declaredFields
for (field in fields) {
if ("mPopup" == field.name) {
field.isAccessible = true
val menuPopupHelper = field.get(popupMenu)
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
setForceIcons.invoke(menuPopupHelper, true)
break
}
}
} catch (e: Exception) {
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
}
popupMenu.setOnMenuItemClickListener { menuItem ->
val noteType = when (menuItem.itemId) {
R.id.action_create_text_note -> NoteType.TEXT
R.id.action_create_checklist -> NoteType.CHECKLIST
else -> return@setOnMenuItemClickListener false
}
val intent = Intent(this, NoteEditorActivity::class.java)
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent)
true
}
popupMenu.show()
}
private fun loadNotes() {
val notes = storage.loadAllNotes()
// Filter out notes that are pending deletion (prevent flicker)
val filteredNotes = notes.filter { it.id !in pendingDeletions }
// Submit list with callback to scroll to top after list is updated
adapter.submitList(filteredNotes) {
// Scroll to top after list update is complete
// Wichtig: Nach dem Erstellen/Bearbeiten einer Notiz
if (filteredNotes.isNotEmpty()) {
recyclerViewNotes.scrollToPosition(0)
}
}
// Material 3 Empty State Card
emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
android.view.View.VISIBLE
} else {
android.view.View.GONE
}
}
private fun openNoteEditor(noteId: String?) {
val intent = Intent(this, NoteEditorActivity::class.java)
noteId?.let {
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_ID, it)
}
startActivity(intent)
}
private fun openSettings() {
// v1.5.0: Use new Jetpack Compose Settings
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
@Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS)
}
private fun triggerManualSync() {
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("manual")) {
return
}
lifecycleScope.launch {
try {
// Create sync service
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
val message = getString(R.string.toast_already_synced)
SyncStateManager.markCompleted(message)
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
// Show result
if (result.isSuccess) {
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
loadNotes() // Reload notes
} else {
SyncStateManager.markError(result.errorMessage)
}
} catch (e: Exception) {
SyncStateManager.markError(e.message)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_settings -> {
openSettings()
true
}
R.id.action_sync -> {
triggerManualSync()
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQUEST_NOTIFICATION_PERMISSION
)
}
}
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
// Restore was successful, reload notes
loadNotes()
}
}
/**
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
*
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
*
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
*
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
*/
private fun migrateChecklistsForBackwardsCompat() {
val migrationKey = "v1.4.1_checklist_migration_done"
// Nur einmal ausführen
if (prefs.getBoolean(migrationKey, false)) {
return
}
val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true
}
if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) {
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
// generiert und hochgeladen wird
val updatedNote = note.copy(
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
)
storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
}
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
}
// Migration als erledigt markieren
prefs.edit().putBoolean(migrationKey, true).apply()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_NOTIFICATION_PERMISSION -> {
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
showToast(getString(R.string.toast_notifications_enabled))
} else {
showToast(getString(R.string.toast_notifications_disabled))
}
}
}
}
/**
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
*/
private fun logLocaleInfo() {
if (!BuildConfig.DEBUG) return
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
// System Locale
val systemLocale = java.util.Locale.getDefault()
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
// Resources Locale
val resourcesLocale = resources.configuration.locales[0]
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
// Context Locale (API 24+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val contextLocales = resources.configuration.locales
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
}
// Test String Loading
val testString = getString(R.string.toast_already_synced)
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
Logger.d(TAG, "║ Result: '$testString'")
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
}
}

View File

@@ -7,6 +7,7 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -24,14 +25,17 @@ import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.DeviceIdGenerator import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.java.KoinJavaComponent
/** /**
* Editor Activity für Notizen und Checklisten * Editor Activity für Notizen und Checklisten
* *
* v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen * v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen
*/ */
class NoteEditorActivity : AppCompatActivity() { class NoteEditorActivity : AppCompatActivity() {
// Views // Views
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var tilTitle: TextInputLayout private lateinit var tilTitle: TextInputLayout
@@ -41,38 +45,36 @@ class NoteEditorActivity : AppCompatActivity() {
private lateinit var checklistContainer: LinearLayout private lateinit var checklistContainer: LinearLayout
private lateinit var rvChecklistItems: RecyclerView private lateinit var rvChecklistItems: RecyclerView
private lateinit var btnAddItem: MaterialButton private lateinit var btnAddItem: MaterialButton
private lateinit var storage: NotesStorage
// State // State
private var existingNote: Note? = null private var existingNote: Note? = null
private var currentNoteType: NoteType = NoteType.TEXT private var currentNoteType: NoteType = NoteType.TEXT
private val checklistItems = mutableListOf<ChecklistItem>() private val checklistItems = mutableListOf<ChecklistItem>()
private var checklistAdapter: ChecklistEditorAdapter? = null private var checklistAdapter: ChecklistEditorAdapter? = null
private var itemTouchHelper: ItemTouchHelper? = null private var itemTouchHelper: ItemTouchHelper? = null
companion object { companion object {
private const val TAG = "NoteEditorActivity" private const val TAG = "NoteEditorActivity"
const val EXTRA_NOTE_ID = "extra_note_id" const val EXTRA_NOTE_ID = "extra_note_id"
const val EXTRA_NOTE_TYPE = "extra_note_type" const val EXTRA_NOTE_TYPE = "extra_note_type"
} }
private val storage: NotesStorage by KoinJavaComponent.inject(NotesStorage::class.java)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Android 12+ (Material You) // Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this) DynamicColors.applyToActivityIfAvailable(this)
setContentView(R.layout.activity_editor) setContentView(R.layout.activity_editor)
storage = NotesStorage(this)
findViews() findViews()
setupToolbar() setupToolbar()
loadNoteOrDetermineType() loadNoteOrDetermineType()
setupUIForNoteType() setupUIForNoteType()
} }
private fun findViews() { private fun findViews() {
toolbar = findViewById(R.id.toolbar) toolbar = findViewById(R.id.toolbar)
tilTitle = findViewById(R.id.tilTitle) tilTitle = findViewById(R.id.tilTitle)
@@ -83,33 +85,36 @@ class NoteEditorActivity : AppCompatActivity() {
rvChecklistItems = findViewById(R.id.rvChecklistItems) rvChecklistItems = findViewById(R.id.rvChecklistItems)
btnAddItem = findViewById(R.id.btnAddItem) btnAddItem = findViewById(R.id.btnAddItem)
} }
private fun setupToolbar() { private fun setupToolbar() {
setSupportActionBar(toolbar) setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
} }
private fun loadNoteOrDetermineType() { private fun loadNoteOrDetermineType() {
val noteId = intent.getStringExtra(EXTRA_NOTE_ID) val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
if (noteId != null) { if (noteId != null) {
// Existierende Notiz laden
existingNote = storage.loadNote(noteId) lifecycleScope.launch {
existingNote?.let { note -> // Existierende Notiz laden
editTextTitle.setText(note.title) existingNote = storage.loadNote(noteId)
currentNoteType = note.noteType existingNote?.let { note ->
editTextTitle.setText(note.title)
when (note.noteType) { currentNoteType = note.noteType
NoteType.TEXT -> {
editTextContent.setText(note.content) when (note.noteType) {
supportActionBar?.title = getString(R.string.edit_note) NoteType.TEXT -> {
} editTextContent.setText(note.content)
NoteType.CHECKLIST -> { supportActionBar?.title = getString(R.string.edit_note)
note.checklistItems?.let { items -> }
checklistItems.clear() NoteType.CHECKLIST -> {
checklistItems.addAll(items.sortedBy { it.order }) note.checklistItems?.let { items ->
checklistItems.clear()
checklistItems.addAll(items.sortedBy { it.order })
}
supportActionBar?.title = getString(R.string.edit_checklist)
} }
supportActionBar?.title = getString(R.string.edit_checklist)
} }
} }
} }
@@ -122,7 +127,7 @@ class NoteEditorActivity : AppCompatActivity() {
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}") Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
NoteType.TEXT NoteType.TEXT
} }
when (currentNoteType) { when (currentNoteType) {
NoteType.TEXT -> { NoteType.TEXT -> {
supportActionBar?.title = getString(R.string.new_note) supportActionBar?.title = getString(R.string.new_note)
@@ -135,7 +140,7 @@ class NoteEditorActivity : AppCompatActivity() {
} }
} }
} }
private fun setupUIForNoteType() { private fun setupUIForNoteType() {
when (currentNoteType) { when (currentNoteType) {
NoteType.TEXT -> { NoteType.TEXT -> {
@@ -149,7 +154,7 @@ class NoteEditorActivity : AppCompatActivity() {
} }
} }
} }
private fun setupChecklistRecyclerView() { private fun setupChecklistRecyclerView() {
checklistAdapter = ChecklistEditorAdapter( checklistAdapter = ChecklistEditorAdapter(
items = checklistItems, items = checklistItems,
@@ -173,12 +178,12 @@ class NoteEditorActivity : AppCompatActivity() {
itemTouchHelper?.startDrag(viewHolder) itemTouchHelper?.startDrag(viewHolder)
} }
) )
rvChecklistItems.apply { rvChecklistItems.apply {
layoutManager = LinearLayoutManager(this@NoteEditorActivity) layoutManager = LinearLayoutManager(this@NoteEditorActivity)
adapter = checklistAdapter adapter = checklistAdapter
} }
// Drag & Drop Setup // Drag & Drop Setup
val callback = object : ItemTouchHelper.SimpleCallback( val callback = object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.UP or ItemTouchHelper.DOWN,
@@ -194,48 +199,48 @@ class NoteEditorActivity : AppCompatActivity() {
checklistAdapter?.moveItem(from, to) checklistAdapter?.moveItem(from, to)
return true return true
} }
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// Nicht verwendet // Nicht verwendet
} }
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
} }
itemTouchHelper = ItemTouchHelper(callback) itemTouchHelper = ItemTouchHelper(callback)
itemTouchHelper?.attachToRecyclerView(rvChecklistItems) itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
// Add Item Button // Add Item Button
btnAddItem.setOnClickListener { btnAddItem.setOnClickListener {
addChecklistItemAt(checklistItems.size) addChecklistItemAt(checklistItems.size)
} }
} }
private fun addChecklistItemAt(position: Int) { private fun addChecklistItemAt(position: Int) {
val newItem = ChecklistItem.createEmpty(position) val newItem = ChecklistItem.createEmpty(position)
checklistAdapter?.insertItem(position, newItem) checklistAdapter?.insertItem(position, newItem)
// Zum neuen Item scrollen und fokussieren // Zum neuen Item scrollen und fokussieren
rvChecklistItems.scrollToPosition(position) rvChecklistItems.scrollToPosition(position)
checklistAdapter?.focusItem(rvChecklistItems, position) checklistAdapter?.focusItem(rvChecklistItems, position)
} }
private fun deleteChecklistItem(position: Int) { private fun deleteChecklistItem(position: Int) {
checklistAdapter?.removeItem(position) checklistAdapter?.removeItem(position)
// Wenn letztes Item gelöscht, automatisch neues hinzufügen // Wenn letztes Item gelöscht, automatisch neues hinzufügen
if (checklistItems.isEmpty()) { if (checklistItems.isEmpty()) {
addChecklistItemAt(0) addChecklistItemAt(0)
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_editor, menu) menuInflater.inflate(R.menu.menu_editor, menu)
// Delete nur für existierende Notizen // Delete nur für existierende Notizen
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
return true return true
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
@@ -253,19 +258,19 @@ class NoteEditorActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
private fun saveNote() { private fun saveNote() {
val title = editTextTitle.text?.toString()?.trim() ?: "" val title = editTextTitle.text?.toString()?.trim() ?: ""
when (currentNoteType) { when (currentNoteType) {
NoteType.TEXT -> { NoteType.TEXT -> {
val content = editTextContent.text?.toString()?.trim() ?: "" val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) { if (title.isEmpty() && content.isEmpty()) {
showToast(getString(R.string.note_is_empty)) showToast(getString(R.string.note_is_empty))
return return
} }
val note = if (existingNote != null) { val note = if (existingNote != null) {
existingNote!!.copy( existingNote!!.copy(
title = title, title = title,
@@ -285,24 +290,24 @@ class NoteEditorActivity : AppCompatActivity() {
syncStatus = SyncStatus.LOCAL_ONLY syncStatus = SyncStatus.LOCAL_ONLY
) )
} }
storage.saveNote(note) lifecycleScope.launch { storage.saveNote(note) }
} }
NoteType.CHECKLIST -> { NoteType.CHECKLIST -> {
// Leere Items filtern // Leere Items filtern
val validItems = checklistItems.filter { it.text.isNotBlank() } val validItems = checklistItems.filter { it.text.isNotBlank() }
if (title.isEmpty() && validItems.isEmpty()) { if (title.isEmpty() && validItems.isEmpty()) {
showToast(getString(R.string.note_is_empty)) showToast(getString(R.string.note_is_empty))
return return
} }
// Order neu setzen // Order neu setzen
val orderedItems = validItems.mapIndexed { index, item -> val orderedItems = validItems.mapIndexed { index, item ->
item.copy(order = index) item.copy(order = index)
} }
val note = if (existingNote != null) { val note = if (existingNote != null) {
existingNote!!.copy( existingNote!!.copy(
title = title, title = title,
@@ -322,15 +327,15 @@ class NoteEditorActivity : AppCompatActivity() {
syncStatus = SyncStatus.LOCAL_ONLY syncStatus = SyncStatus.LOCAL_ONLY
) )
} }
storage.saveNote(note) lifecycleScope.launch { storage.saveNote(note) }
} }
} }
showToast(getString(R.string.note_saved)) showToast(getString(R.string.note_saved))
finish() finish()
} }
private fun confirmDelete() { private fun confirmDelete() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(getString(R.string.delete_note_title)) .setTitle(getString(R.string.delete_note_title))
@@ -341,10 +346,10 @@ class NoteEditorActivity : AppCompatActivity() {
.setNegativeButton(getString(R.string.cancel), null) .setNegativeButton(getString(R.string.cancel), null)
.show() .show()
} }
private fun deleteNote() { private fun deleteNote() {
existingNote?.let { existingNote?.let {
storage.deleteNote(it.id) lifecycleScope.launch { storage.deleteNote(it.id) }
showToast(getString(R.string.note_deleted)) showToast(getString(R.string.note_deleted))
finish() finish()
} }

View File

@@ -11,21 +11,24 @@ import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.java.KoinJavaComponent.inject
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.getValue
/** /**
* BackupManager: Lokale Backup & Restore Funktionalität * BackupManager: Lokale Backup & Restore Funktionalität
* *
* Features: * Features:
* - Backup aller Notizen in JSON-Datei * - Backup aller Notizen in JSON-Datei
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates) * - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
* - Auto-Backup vor Restore (Sicherheitsnetz) * - Auto-Backup vor Restore (Sicherheitsnetz)
* - Backup-Validierung * - Backup-Validierung
*/ */
class BackupManager(private val context: Context) { class BackupManager(private val context: Context): KoinComponent {
companion object { companion object {
private const val TAG = "BackupManager" private const val TAG = "BackupManager"
private const val BACKUP_VERSION = 1 private const val BACKUP_VERSION = 1
@@ -33,14 +36,14 @@ class BackupManager(private val context: Context) {
private const val AUTO_BACKUP_RETENTION_DAYS = 7 private const val AUTO_BACKUP_RETENTION_DAYS = 7
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
} }
private val storage = NotesStorage(context) private val storage: NotesStorage by inject(NotesStorage::class.java)
private val gson: Gson = GsonBuilder().setPrettyPrinting().create() private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0 private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
/** /**
* Erstellt Backup aller Notizen * Erstellt Backup aller Notizen
* *
* @param uri Output-URI (via Storage Access Framework) * @param uri Output-URI (via Storage Access Framework)
* @param password Optional password for encryption (null = unencrypted) * @param password Optional password for encryption (null = unencrypted)
* @return BackupResult mit Erfolg/Fehler Info * @return BackupResult mit Erfolg/Fehler Info
@@ -49,10 +52,10 @@ class BackupManager(private val context: Context) {
return@withContext try { return@withContext try {
val encryptedSuffix = if (password != null) " (encrypted)" else "" val encryptedSuffix = if (password != null) " (encrypted)" else ""
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri") Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
Logger.d(TAG, " Found ${allNotes.size} notes to backup") Logger.d(TAG, " Found ${allNotes.size} notes to backup")
val backupData = BackupData( val backupData = BackupData(
backupVersion = BACKUP_VERSION, backupVersion = BACKUP_VERSION,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
@@ -60,27 +63,27 @@ class BackupManager(private val context: Context) {
appVersion = BuildConfig.VERSION_NAME, appVersion = BuildConfig.VERSION_NAME,
notes = allNotes notes = allNotes
) )
val jsonString = gson.toJson(backupData) val jsonString = gson.toJson(backupData)
// 🔐 v1.7.0: Encrypt if password is provided // 🔐 v1.7.0: Encrypt if password is provided
val dataToWrite = if (password != null) { val dataToWrite = if (password != null) {
encryptionManager.encrypt(jsonString.toByteArray(), password) encryptionManager.encrypt(jsonString.toByteArray(), password)
} else { } else {
jsonString.toByteArray() jsonString.toByteArray()
} }
context.contentResolver.openOutputStream(uri)?.use { outputStream -> context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(dataToWrite) outputStream.write(dataToWrite)
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix") Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
} }
BackupResult( BackupResult(
success = true, success = true,
notesCount = allNotes.size, notesCount = allNotes.size,
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix" message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to create backup", e) Logger.e(TAG, "Failed to create backup", e)
BackupResult( BackupResult(
@@ -89,11 +92,11 @@ class BackupManager(private val context: Context) {
) )
} }
} }
/** /**
* Erstellt automatisches Backup (vor Restore) * Erstellt automatisches Backup (vor Restore)
* Gespeichert in app-internem Storage * Gespeichert in app-internem Storage
* *
* @return Uri des Auto-Backups oder null bei Fehler * @return Uri des Auto-Backups oder null bei Fehler
*/ */
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) { suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
@@ -101,14 +104,14 @@ class BackupManager(private val context: Context) {
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply { val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
.format(Date()) .format(Date())
val filename = "auto_backup_before_restore_$timestamp.json" val filename = "auto_backup_before_restore_$timestamp.json"
val file = File(autoBackupDir, filename) val file = File(autoBackupDir, filename)
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}") Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val backupData = BackupData( val backupData = BackupData(
backupVersion = BACKUP_VERSION, backupVersion = BACKUP_VERSION,
@@ -117,24 +120,24 @@ class BackupManager(private val context: Context) {
appVersion = BuildConfig.VERSION_NAME, appVersion = BuildConfig.VERSION_NAME,
notes = allNotes notes = allNotes
) )
file.writeText(gson.toJson(backupData)) file.writeText(gson.toJson(backupData))
// Cleanup alte Auto-Backups // Cleanup alte Auto-Backups
cleanupOldAutoBackups(autoBackupDir) cleanupOldAutoBackups(autoBackupDir)
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}") Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
Uri.fromFile(file) Uri.fromFile(file)
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to create auto-backup", e) Logger.e(TAG, "Failed to create auto-backup", e)
null null
} }
} }
/** /**
* Stellt Notizen aus Backup wieder her * Stellt Notizen aus Backup wieder her
* *
* @param uri Backup-Datei URI * @param uri Backup-Datei URI
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite) * @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
* @param password Optional password if backup is encrypted * @param password Optional password if backup is encrypted
@@ -143,7 +146,7 @@ class BackupManager(private val context: Context) {
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) { suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)") Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
// 1. Backup-Datei lesen // 1. Backup-Datei lesen
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream -> val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.readBytes() inputStream.readBytes()
@@ -151,7 +154,7 @@ class BackupManager(private val context: Context) {
success = false, success = false,
error = "Datei konnte nicht gelesen werden" error = "Datei konnte nicht gelesen werden"
) )
// 🔐 v1.7.0: Check if encrypted and decrypt if needed // 🔐 v1.7.0: Check if encrypted and decrypt if needed
val jsonString = try { val jsonString = try {
if (encryptionManager.isEncrypted(fileData)) { if (encryptionManager.isEncrypted(fileData)) {
@@ -172,7 +175,7 @@ class BackupManager(private val context: Context) {
error = "Entschlüsselung fehlgeschlagen: ${e.message}" error = "Entschlüsselung fehlgeschlagen: ${e.message}"
) )
} }
// 2. Backup validieren & parsen // 2. Backup validieren & parsen
val validationResult = validateBackup(jsonString) val validationResult = validateBackup(jsonString)
if (!validationResult.isValid) { if (!validationResult.isValid) {
@@ -181,26 +184,26 @@ class BackupManager(private val context: Context) {
error = validationResult.errorMessage ?: context.getString(R.string.error_invalid_backup_file) error = validationResult.errorMessage ?: context.getString(R.string.error_invalid_backup_file)
) )
} }
val backupData = gson.fromJson(jsonString, BackupData::class.java) val backupData = gson.fromJson(jsonString, BackupData::class.java)
Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}") Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
// 3. Auto-Backup erstellen (Sicherheitsnetz) // 3. Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = createAutoBackup() val autoBackupUri = createAutoBackup()
if (autoBackupUri == null) { if (autoBackupUri == null) {
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore") Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
} }
// 4. Restore durchführen (je nach Modus) // 4. Restore durchführen (je nach Modus)
val result = when (mode) { val result = when (mode) {
RestoreMode.MERGE -> restoreMerge(backupData.notes) RestoreMode.MERGE -> restoreMerge(backupData.notes)
RestoreMode.REPLACE -> restoreReplace(backupData.notes) RestoreMode.REPLACE -> restoreReplace(backupData.notes)
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes) RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
} }
Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped") Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
result result
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to restore backup", e) Logger.e(TAG, "Failed to restore backup", e)
RestoreResult( RestoreResult(
@@ -209,7 +212,7 @@ class BackupManager(private val context: Context) {
) )
} }
} }
/** /**
* 🔐 v1.7.0: Check if backup file is encrypted * 🔐 v1.7.0: Check if backup file is encrypted
*/ */
@@ -225,14 +228,14 @@ class BackupManager(private val context: Context) {
false false
} }
} }
/** /**
* Validiert Backup-Datei * Validiert Backup-Datei
*/ */
private fun validateBackup(jsonString: String): ValidationResult { private fun validateBackup(jsonString: String): ValidationResult {
return try { return try {
val backupData = gson.fromJson(jsonString, BackupData::class.java) val backupData = gson.fromJson(jsonString, BackupData::class.java)
// Version kompatibel? // Version kompatibel?
if (backupData.backupVersion > BACKUP_VERSION) { if (backupData.backupVersion > BACKUP_VERSION) {
return ValidationResult( return ValidationResult(
@@ -240,7 +243,7 @@ class BackupManager(private val context: Context) {
errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION) errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
) )
} }
// Notizen-Array vorhanden? // Notizen-Array vorhanden?
if (backupData.notes.isEmpty()) { if (backupData.notes.isEmpty()) {
return ValidationResult( return ValidationResult(
@@ -248,21 +251,21 @@ class BackupManager(private val context: Context) {
errorMessage = context.getString(R.string.error_backup_empty) errorMessage = context.getString(R.string.error_backup_empty)
) )
} }
// Alle Notizen haben ID, title, content? // Alle Notizen haben ID, title, content?
val invalidNotes = backupData.notes.filter { note -> val invalidNotes = backupData.notes.filter { note ->
note.id.isBlank() || note.title.isBlank() note.id.isBlank() || note.title.isBlank()
} }
if (invalidNotes.isNotEmpty()) { if (invalidNotes.isNotEmpty()) {
return ValidationResult( return ValidationResult(
isValid = false, isValid = false,
errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size) errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size)
) )
} }
ValidationResult(isValid = true) ValidationResult(isValid = true)
} catch (e: Exception) { } catch (e: Exception) {
ValidationResult( ValidationResult(
isValid = false, isValid = false,
@@ -270,22 +273,22 @@ class BackupManager(private val context: Context) {
) )
} }
} }
/** /**
* Restore-Modus: MERGE * Restore-Modus: MERGE
* Fügt neue Notizen hinzu, behält bestehende * Fügt neue Notizen hinzu, behält bestehende
*/ */
private fun restoreMerge(backupNotes: List<Note>): RestoreResult { private suspend fun restoreMerge(backupNotes: List<Note>): RestoreResult {
val existingNotes = storage.loadAllNotes() val existingNotes = storage.loadAllNotes()
val existingIds = existingNotes.map { it.id }.toSet() val existingIds = existingNotes.map { it.id }.toSet()
val newNotes = backupNotes.filter { it.id !in existingIds } val newNotes = backupNotes.filter { it.id !in existingIds }
val skippedNotes = backupNotes.size - newNotes.size val skippedNotes = backupNotes.size - newNotes.size
newNotes.forEach { note -> newNotes.forEach { note ->
storage.saveNote(note) storage.saveNote(note)
} }
return RestoreResult( return RestoreResult(
success = true, success = true,
importedNotes = newNotes.size, importedNotes = newNotes.size,
@@ -293,20 +296,20 @@ class BackupManager(private val context: Context) {
message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes) message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes)
) )
} }
/** /**
* Restore-Modus: REPLACE * Restore-Modus: REPLACE
* Löscht alle bestehenden Notizen, importiert Backup * Löscht alle bestehenden Notizen, importiert Backup
*/ */
private fun restoreReplace(backupNotes: List<Note>): RestoreResult { private suspend fun restoreReplace(backupNotes: List<Note>): RestoreResult {
// Alle bestehenden Notizen löschen // Alle bestehenden Notizen löschen
storage.deleteAllNotes() storage.deleteAllNotes()
// Backup-Notizen importieren // Backup-Notizen importieren
backupNotes.forEach { note -> backupNotes.forEach { note ->
storage.saveNote(note) storage.saveNote(note)
} }
return RestoreResult( return RestoreResult(
success = true, success = true,
importedNotes = backupNotes.size, importedNotes = backupNotes.size,
@@ -319,18 +322,18 @@ class BackupManager(private val context: Context) {
* Restore-Modus: OVERWRITE_DUPLICATES * Restore-Modus: OVERWRITE_DUPLICATES
* Backup überschreibt bei ID-Konflikten * Backup überschreibt bei ID-Konflikten
*/ */
private fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult { private suspend fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
val existingNotes = storage.loadAllNotes() val existingNotes = storage.loadAllNotes()
val existingIds = existingNotes.map { it.id }.toSet() val existingIds = existingNotes.map { it.id }.toSet()
val newNotes = backupNotes.filter { it.id !in existingIds } val newNotes = backupNotes.filter { it.id !in existingIds }
val overwrittenNotes = backupNotes.filter { it.id in existingIds } val overwrittenNotes = backupNotes.filter { it.id in existingIds }
// Alle Backup-Notizen speichern (überschreibt automatisch) // Alle Backup-Notizen speichern (überschreibt automatisch)
backupNotes.forEach { note -> backupNotes.forEach { note ->
storage.saveNote(note) storage.saveNote(note)
} }
return RestoreResult( return RestoreResult(
success = true, success = true,
importedNotes = newNotes.size, importedNotes = newNotes.size,
@@ -339,7 +342,7 @@ class BackupManager(private val context: Context) {
message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size) message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
) )
} }
/** /**
* Löscht Auto-Backups älter als RETENTION_DAYS * Löscht Auto-Backups älter als RETENTION_DAYS
*/ */
@@ -347,7 +350,7 @@ class BackupManager(private val context: Context) {
try { try {
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
val cutoffTime = System.currentTimeMillis() - retentionTimeMs val cutoffTime = System.currentTimeMillis() - retentionTimeMs
autoBackupDir.listFiles()?.forEach { file -> autoBackupDir.listFiles()?.forEach { file ->
if (file.lastModified() < cutoffTime) { if (file.lastModified() < cutoffTime) {
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}") Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")

View File

@@ -2,11 +2,11 @@ package dev.dettmer.simplenotes.di
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
import dev.dettmer.simplenotes.MainViewModel
import dev.dettmer.simplenotes.storage.AppDatabase import dev.dettmer.simplenotes.storage.AppDatabase
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.ui.main.MainViewModel import dev.dettmer.simplenotes.ui.main.MainViewModel
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
@@ -23,12 +23,14 @@ val appModule = module {
single { get<AppDatabase>().noteDao() } single { get<AppDatabase>().noteDao() }
single { get<AppDatabase>().deletedNoteDao() } single { get<AppDatabase>().deletedNoteDao() }
single { NotesStorage(androidContext(), get(), get()) }
// Provide SharedPreferences // Provide SharedPreferences
single { single {
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
} }
single { NotesStorage(androidContext(), get()) }
viewModel { MainViewModel(get(), get(), get()) }
viewModel { MainViewModel(androidApplication()) }
} }

View File

@@ -1,13 +1,18 @@
package dev.dettmer.simplenotes.storage package dev.dettmer.simplenotes.storage
import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import dev.dettmer.simplenotes.storage.converter.NoteConverters
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
import dev.dettmer.simplenotes.storage.dao.NoteDao import dev.dettmer.simplenotes.storage.dao.NoteDao
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1) @Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
@TypeConverters(NoteConverters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao abstract fun noteDao(): NoteDao
abstract fun deletedNoteDao(): DeletedNoteDao abstract fun deletedNoteDao(): DeletedNoteDao

View File

@@ -1,59 +1,101 @@
package dev.dettmer.simplenotes.storage package dev.dettmer.simplenotes.storage
import android.content.Context import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
import dev.dettmer.simplenotes.storage.dao.NoteDao
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity import dev.dettmer.simplenotes.storage.entity.NoteEntity
import dev.dettmer.simplenotes.utils.DeviceIdGenerator import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import java.io.File
class NotesStorage( class NotesStorage(
private val context: Context, private val context: Context,
database: AppDatabase private val noteDao: NoteDao,
private val deletedNoteDao: DeletedNoteDao,
) { ) {
companion object { companion object {
private const val TAG = "NotesStorage" private const val TAG = "NotesStorage"
} }
private val noteDao = database.noteDao()
private val deletedNoteDao = database.deletedNoteDao()
suspend fun saveNote(note: NoteEntity) { suspend fun saveNote(note: Note) {
noteDao.saveNote(note) noteDao.saveNote(
NoteEntity(
id = note.id,
title = note.title,
content = note.content,
createdAt = note.createdAt,
updatedAt = note.updatedAt,
deviceId = note.deviceId,
syncStatus = note.syncStatus,
noteType = note.noteType,
checklistItems = note.checklistItems,
checklistSortOption = note.checklistSortOption
)
)
} }
suspend fun loadNote(id: String): NoteEntity? { suspend fun loadNote(id: String): Note? {
return noteDao.getNote(id) return noteDao.getNote(id)?.let { note ->
Note(
id = note.id,
title = note.title,
content = note.content,
createdAt = note.createdAt,
updatedAt = note.updatedAt,
deviceId = note.deviceId,
syncStatus = note.syncStatus,
noteType = note.noteType,
checklistItems = note.checklistItems,
checklistSortOption = note.checklistSortOption
)
}
} }
suspend fun loadAllNotes(): List<NoteEntity> { suspend fun loadAllNotes(): List<Note> {
return noteDao.getAllNotes() return noteDao.getAllNotes().map { note ->
Note(
id = note.id,
title = note.title,
content = note.content,
createdAt = note.createdAt,
updatedAt = note.updatedAt,
deviceId = note.deviceId,
syncStatus = note.syncStatus,
noteType = note.noteType,
checklistItems = note.checklistItems,
checklistSortOption = note.checklistSortOption
)
}
} }
suspend fun deleteNote(id: String): Boolean { suspend fun deleteNote(id: String): Boolean {
val deletedRows = noteDao.deleteNoteById(id) val deleted = noteDao.deleteNoteById(id) > 0
if (deletedRows > 0) { if (deleted) {
Logger.d(TAG, "🗑️ Deleted note: $id")
val deviceId = DeviceIdGenerator.getDeviceId(context) val deviceId = DeviceIdGenerator.getDeviceId(context)
trackDeletionSafe(id, deviceId) deletedNoteDao.trackDeletion(DeletedNoteEntity(id, deviceId))
return true
} }
return false
return deleted
} }
suspend fun deleteAllNotes(): Boolean { suspend fun deleteAllNotes(): Boolean {
return try { return try {
val notes = loadAllNotes() val notes = noteDao.getAllNotes()
val deviceId = DeviceIdGenerator.getDeviceId(context)
// Batch tracking and deleting
notes.forEach { note ->
trackDeletionSafe(note.id, deviceId)
}
noteDao.deleteAllNotes() noteDao.deleteAllNotes()
for (note in notes) {
val deviceId = DeviceIdGenerator.getDeviceId(context)
deletedNoteDao.trackDeletion(DeletedNoteEntity(note.id, deviceId))
}
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)") Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
true true
} catch (e: Exception) { } catch (e: Exception) {
@@ -64,9 +106,60 @@ class NotesStorage(
// === Deletion Tracking === // === Deletion Tracking ===
private fun getDeletionTrackerFile(): File {
return File(context.filesDir, "deleted_notes.json")
}
fun loadDeletionTracker(): DeletionTracker {
val file = getDeletionTrackerFile()
if (!file.exists()) {
return DeletionTracker()
}
return try {
val json = file.readText()
DeletionTracker.fromJson(json) ?: DeletionTracker()
} catch (e: Exception) {
Logger.e(TAG, "Failed to load deletion tracker", e)
DeletionTracker()
}
}
fun saveDeletionTracker(tracker: DeletionTracker) {
try {
val file = getDeletionTrackerFile()
file.writeText(tracker.toJson())
if (tracker.deletedNotes.size > 1000) {
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
}
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
} catch (e: Exception) {
Logger.e(TAG, "Failed to save deletion tracker", e)
}
}
/**
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
*
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
* auf den Deletion Tracker.
*
* @param noteId ID der gelöschten Notiz
* @param deviceId Geräte-ID für Konflikt-Erkennung
*/
suspend fun trackDeletionSafe(noteId: String, deviceId: String) { suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
// Room handles internal transactions and thread-safety natively. deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
// The Mutex is no longer required. }
/**
* Legacy-Methode ohne Mutex-Schutz.
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
*
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
*/
suspend fun trackDeletion(noteId: String, deviceId: String) {
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId)) deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
Logger.d(TAG, "📝 Tracked deletion: $noteId") Logger.d(TAG, "📝 Tracked deletion: $noteId")
} }
@@ -77,14 +170,17 @@ class NotesStorage(
suspend fun clearDeletionTracker() { suspend fun clearDeletionTracker() {
deletedNoteDao.clearTracker() deletedNoteDao.clearTracker()
Logger.d(TAG, "🗑️ Deletion tracker cleared") 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
*/
suspend fun resetAllSyncStatusToPending(): Int { suspend fun resetAllSyncStatusToPending(): Int {
val updatedCount = noteDao.updateSyncStatus( var updatedCount = noteDao.updateSyncStatus(SyncStatus.SYNCED, SyncStatus.PENDING)
oldStatus = SyncStatus.SYNCED,
newStatus = SyncStatus.PENDING
)
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING") Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount return updatedCount
} }

View File

@@ -0,0 +1,39 @@
package dev.dettmer.simplenotes.storage.converter
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
class NoteConverters {
private val gson = Gson()
// --- NoteType Enum ---
@TypeConverter
fun fromNoteType(value: NoteType): String = value.name
@TypeConverter
fun toNoteType(value: String): NoteType = NoteType.valueOf(value)
// --- SyncStatus Enum ---
@TypeConverter
fun fromSyncStatus(value: SyncStatus): String = value.name
@TypeConverter
fun toSyncStatus(value: String): SyncStatus = SyncStatus.valueOf(value)
// --- ChecklistItem List ---
@TypeConverter
fun fromChecklistItems(items: List<ChecklistItem>?): String? {
return items?.let { gson.toJson(it) }
}
@TypeConverter
fun toChecklistItems(json: String?): List<ChecklistItem>? {
if (json == null) return null
val type = object : TypeToken<List<ChecklistItem>>() {}.type
return gson.fromJson(json, type)
}
}

View File

@@ -2,12 +2,21 @@ package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
@Entity(tableName = "notes") @Entity(tableName = "notes")
data class NoteEntity( data class NoteEntity(
@PrimaryKey val id: String, @PrimaryKey
val id: String,
val title: String,
val content: String, val content: String,
val timestamp: Long, val createdAt: Long,
val syncStatus: SyncStatus val updatedAt: Long,
val deviceId: String,
val syncStatus: SyncStatus,
val noteType: NoteType,
val checklistItems: List<ChecklistItem>?, // Handled by TypeConverter
val checklistSortOption: String?
) )

View File

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.editor
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -29,67 +30,69 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.inject
import java.util.UUID import java.util.UUID
import kotlin.getValue
/** /**
* ViewModel for NoteEditor Compose Screen * ViewModel for NoteEditor Compose Screen
* v1.5.0: Jetpack Compose NoteEditor Redesign * v1.5.0: Jetpack Compose NoteEditor Redesign
* *
* Manages note editing state including title, content, and checklist items. * Manages note editing state including title, content, and checklist items.
*/ */
class NoteEditorViewModel( class NoteEditorViewModel(
application: Application, application: Application,
private val savedStateHandle: SavedStateHandle private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
companion object { companion object {
private const val TAG = "NoteEditorViewModel" private const val TAG = "NoteEditorViewModel"
const val ARG_NOTE_ID = "noteId" const val ARG_NOTE_ID = "noteId"
const val ARG_NOTE_TYPE = "noteType" const val ARG_NOTE_TYPE = "noteType"
} }
private val storage = NotesStorage(application) private val storage: NotesStorage by inject(NotesStorage::class.java)
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// State // State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _uiState = MutableStateFlow(NoteEditorUiState()) private val _uiState = MutableStateFlow(NoteEditorUiState())
val uiState: StateFlow<NoteEditorUiState> = _uiState.asStateFlow() val uiState: StateFlow<NoteEditorUiState> = _uiState.asStateFlow()
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList()) private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow() val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
// 🌟 v1.6.0: Offline Mode State // 🌟 v1.6.0: Offline Mode State
private val _isOfflineMode = MutableStateFlow( private val _isOfflineMode = MutableStateFlow(
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true) prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
) )
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow() val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
// 🔀 v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope) // 🔀 v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope)
private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL) private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL)
val lastChecklistSortOption: StateFlow<ChecklistSortOption> = _lastChecklistSortOption.asStateFlow() val lastChecklistSortOption: StateFlow<ChecklistSortOption> = _lastChecklistSortOption.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Events // Events
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _events = MutableSharedFlow<NoteEditorEvent>() private val _events = MutableSharedFlow<NoteEditorEvent>()
val events: SharedFlow<NoteEditorEvent> = _events.asSharedFlow() val events: SharedFlow<NoteEditorEvent> = _events.asSharedFlow()
// Internal state // Internal state
private var existingNote: Note? = null private var existingNote: Note? = null
private var currentNoteType: NoteType = NoteType.TEXT private var currentNoteType: NoteType = NoteType.TEXT
init { init {
loadNote() loadNote()
} }
private fun loadNote() { private fun loadNote() {
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID) val noteId = savedStateHandle.get<String>(ARG_NOTE_ID)
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
if (noteId != null) { if (noteId != null) {
loadExistingNote(noteId) loadExistingNote(noteId)
} else { } else {
@@ -97,7 +100,7 @@ class NoteEditorViewModel(
} }
} }
private fun loadExistingNote(noteId: String) { private fun loadExistingNote(noteId: String) = viewModelScope.launch{
existingNote = storage.loadNote(noteId) existingNote = storage.loadNote(noteId)
existingNote?.let { note -> existingNote?.let { note ->
currentNoteType = note.noteType currentNoteType = note.noteType
@@ -114,7 +117,7 @@ class NoteEditorViewModel(
} }
) )
} }
if (note.noteType == NoteType.CHECKLIST) { if (note.noteType == NoteType.CHECKLIST) {
loadChecklistData(note) loadChecklistData(note)
} }
@@ -126,7 +129,7 @@ class NoteEditorViewModel(
note.checklistSortOption?.let { sortName -> note.checklistSortOption?.let { sortName ->
_lastChecklistSortOption.value = parseSortOption(sortName) _lastChecklistSortOption.value = parseSortOption(sortName)
} }
val items = note.checklistItems?.sortedBy { it.order }?.map { val items = note.checklistItems?.sortedBy { it.order }?.map {
ChecklistItemState( ChecklistItemState(
id = it.id, id = it.id,
@@ -146,7 +149,7 @@ class NoteEditorViewModel(
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT") Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
NoteType.TEXT NoteType.TEXT
} }
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
noteType = currentNoteType, noteType = currentNoteType,
@@ -158,7 +161,7 @@ class NoteEditorViewModel(
} }
) )
} }
// Add first empty item for new checklists // Add first empty item for new checklists
if (currentNoteType == NoteType.CHECKLIST) { if (currentNoteType == NoteType.CHECKLIST) {
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0)) _checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
@@ -177,19 +180,19 @@ class NoteEditorViewModel(
ChecklistSortOption.MANUAL ChecklistSortOption.MANUAL
} }
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Actions // Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
fun updateTitle(title: String) { fun updateTitle(title: String) {
_uiState.update { it.copy(title = title) } _uiState.update { it.copy(title = title) }
} }
fun updateContent(content: String) { fun updateContent(content: String) {
_uiState.update { it.copy(content = content) } _uiState.update { it.copy(content = content) }
} }
fun updateChecklistItemText(itemId: String, newText: String) { fun updateChecklistItemText(itemId: String, newText: String) {
_checklistItems.update { items -> _checklistItems.update { items ->
items.map { item -> items.map { item ->
@@ -197,7 +200,7 @@ class NoteEditorViewModel(
} }
} }
} }
/** /**
* 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten. * 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten. * Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten.
@@ -243,7 +246,7 @@ class NoteEditorViewModel(
} }
} }
} }
/** /**
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein. * 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein.
* *
@@ -320,7 +323,7 @@ class NoteEditorViewModel(
else -> items.size else -> items.size
} }
} }
fun deleteChecklistItem(itemId: String) { fun deleteChecklistItem(itemId: String) {
_checklistItems.update { items -> _checklistItems.update { items ->
val filtered = items.filter { it.id != itemId } val filtered = items.filter { it.id != itemId }
@@ -333,7 +336,7 @@ class NoteEditorViewModel(
} }
} }
} }
fun moveChecklistItem(fromIndex: Int, toIndex: Int) { fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
_checklistItems.update { items -> _checklistItems.update { items ->
val fromItem = items.getOrNull(fromIndex) ?: return@update items val fromItem = items.getOrNull(fromIndex) ?: return@update items
@@ -355,7 +358,7 @@ class NoteEditorViewModel(
mutableList.mapIndexed { index, i -> i.copy(order = index) } mutableList.mapIndexed { index, i -> i.copy(order = index) }
} }
} }
/** /**
* 🔀 v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewählter Option. * 🔀 v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewählter Option.
* Einmalige Aktion (nicht persistiert) — User kann danach per Drag & Drop feinjustieren. * Einmalige Aktion (nicht persistiert) — User kann danach per Drag & Drop feinjustieren.
@@ -363,44 +366,44 @@ class NoteEditorViewModel(
fun sortChecklistItems(option: ChecklistSortOption) { fun sortChecklistItems(option: ChecklistSortOption) {
// Merke die Auswahl für diesen Editor-Session // Merke die Auswahl für diesen Editor-Session
_lastChecklistSortOption.value = option _lastChecklistSortOption.value = option
_checklistItems.update { items -> _checklistItems.update { items ->
val sorted = when (option) { val sorted = when (option) {
// Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird // Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird
ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked } ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked }
ChecklistSortOption.ALPHABETICAL_ASC -> ChecklistSortOption.ALPHABETICAL_ASC ->
items.sortedBy { it.text.lowercase() } items.sortedBy { it.text.lowercase() }
ChecklistSortOption.ALPHABETICAL_DESC -> ChecklistSortOption.ALPHABETICAL_DESC ->
items.sortedByDescending { it.text.lowercase() } items.sortedByDescending { it.text.lowercase() }
ChecklistSortOption.UNCHECKED_FIRST -> ChecklistSortOption.UNCHECKED_FIRST ->
items.sortedBy { it.isChecked } items.sortedBy { it.isChecked }
ChecklistSortOption.CHECKED_FIRST -> ChecklistSortOption.CHECKED_FIRST ->
items.sortedByDescending { it.isChecked } items.sortedByDescending { it.isChecked }
} }
// Order-Werte neu zuweisen // Order-Werte neu zuweisen
sorted.mapIndexed { index, item -> item.copy(order = index) } sorted.mapIndexed { index, item -> item.copy(order = index) }
} }
} }
fun saveNote() { fun saveNote() {
viewModelScope.launch { viewModelScope.launch {
val state = _uiState.value val state = _uiState.value
val title = state.title.trim() val title = state.title.trim()
when (currentNoteType) { when (currentNoteType) {
NoteType.TEXT -> { NoteType.TEXT -> {
val content = state.content.trim() val content = state.content.trim()
if (title.isEmpty() && content.isEmpty()) { if (title.isEmpty() && content.isEmpty()) {
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY)) _events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
return@launch return@launch
} }
val note = if (existingNote != null) { val note = if (existingNote != null) {
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt // 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc. // beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
@@ -422,10 +425,10 @@ class NoteEditorViewModel(
syncStatus = SyncStatus.LOCAL_ONLY syncStatus = SyncStatus.LOCAL_ONLY
) )
} }
storage.saveNote(note) storage.saveNote(note)
} }
NoteType.CHECKLIST -> { NoteType.CHECKLIST -> {
// Filter empty items // Filter empty items
val validItems = _checklistItems.value val validItems = _checklistItems.value
@@ -438,12 +441,12 @@ class NoteEditorViewModel(
order = index order = index
) )
} }
if (title.isEmpty() && validItems.isEmpty()) { if (title.isEmpty() && validItems.isEmpty()) {
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY)) _events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
return@launch return@launch
} }
val note = if (existingNote != null) { val note = if (existingNote != null) {
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt // 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc. // beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
@@ -467,11 +470,11 @@ class NoteEditorViewModel(
syncStatus = SyncStatus.LOCAL_ONLY syncStatus = SyncStatus.LOCAL_ONLY
) )
} }
storage.saveNote(note) storage.saveNote(note)
} }
} }
// 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend // 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend
// 🌟 v1.6.0: Trigger onSave Sync // 🌟 v1.6.0: Trigger onSave Sync
@@ -491,7 +494,7 @@ class NoteEditorViewModel(
_events.emit(NoteEditorEvent.NavigateBack) _events.emit(NoteEditorEvent.NavigateBack)
} }
} }
/** /**
* Delete the current note * Delete the current note
* @param deleteOnServer if true, also triggers server deletion; if false, only deletes locally * @param deleteOnServer if true, also triggers server deletion; if false, only deletes locally
@@ -501,10 +504,10 @@ class NoteEditorViewModel(
viewModelScope.launch { viewModelScope.launch {
existingNote?.let { note -> existingNote?.let { note ->
val noteId = note.id val noteId = note.id
// Delete locally first // Delete locally first
storage.deleteNote(noteId) storage.deleteNote(noteId)
// Delete from server if requested // Delete from server if requested
if (deleteOnServer) { if (deleteOnServer) {
try { try {
@@ -538,18 +541,18 @@ class NoteEditorViewModel(
) )
} }
} }
_events.emit(NoteEditorEvent.NavigateBack) _events.emit(NoteEditorEvent.NavigateBack)
} }
} }
} }
fun showDeleteConfirmation() { fun showDeleteConfirmation() {
viewModelScope.launch { viewModelScope.launch {
_events.emit(NoteEditorEvent.ShowDeleteConfirmation) _events.emit(NoteEditorEvent.ShowDeleteConfirmation)
} }
} }
fun canDelete(): Boolean = existingNote != null fun canDelete(): Boolean = existingNote != null
/** /**
@@ -564,10 +567,10 @@ class NoteEditorViewModel(
* Nur checklistItems werden aktualisiert — nicht title oder content, * Nur checklistItems werden aktualisiert — nicht title oder content,
* damit ungespeicherte Text-Änderungen im Editor nicht verloren gehen. * damit ungespeicherte Text-Änderungen im Editor nicht verloren gehen.
*/ */
fun reloadFromStorage() { fun reloadFromStorage() = viewModelScope.launch{
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID) ?: return val noteId = savedStateHandle.get<String>(ARG_NOTE_ID) ?: return@launch
val freshNote = storage.loadNote(noteId) ?: return val freshNote = storage.loadNote(noteId) ?: return@launch
// Nur Checklist-Items aktualisieren // Nur Checklist-Items aktualisieren
if (freshNote.noteType == NoteType.CHECKLIST) { if (freshNote.noteType == NoteType.CHECKLIST) {
@@ -578,7 +581,7 @@ class NoteEditorViewModel(
isChecked = it.isChecked, isChecked = it.isChecked,
order = it.order order = it.order
) )
} ?: return } ?: return@launch
_checklistItems.value = sortChecklistItems(freshItems) _checklistItems.value = sortChecklistItems(freshItems)
// existingNote aktualisieren damit beim Speichern der richtige // existingNote aktualisieren damit beim Speichern der richtige
@@ -586,16 +589,16 @@ class NoteEditorViewModel(
existingNote = freshNote existingNote = freshNote
} }
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Sync Trigger - onSave // 🌟 v1.6.0: Sync Trigger - onSave
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
/** /**
* Triggers sync after saving a note (if enabled and server configured) * Triggers sync after saving a note (if enabled and server configured)
* v1.6.0: New configurable sync trigger * v1.6.0: New configurable sync trigger
* v1.7.0: Uses central canSync() gate for WiFi-only check * v1.7.0: Uses central canSync() gate for WiFi-only check
* *
* Separate throttling (5 seconds) to prevent spam when saving multiple times * Separate throttling (5 seconds) to prevent spam when saving multiple times
*/ */
private fun triggerOnSaveSync() { private fun triggerOnSaveSync() {
@@ -604,7 +607,7 @@ class NoteEditorViewModel(
Logger.d(TAG, "⏭️ onSave sync disabled - skipping") Logger.d(TAG, "⏭️ onSave sync disabled - skipping")
return return
} }
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config) // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
val syncService = WebDavSyncService(getApplication()) val syncService = WebDavSyncService(getApplication())
val gateResult = syncService.canSync() val gateResult = syncService.canSync()
@@ -616,21 +619,21 @@ class NoteEditorViewModel(
} }
return return
} }
// Check 2: Throttling (5 seconds) to prevent spam // Check 2: Throttling (5 seconds) to prevent spam
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0) val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastOnSaveSyncTime val timeSinceLastSync = now - lastOnSaveSyncTime
if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) { if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) {
val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000 val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "⏳ onSave sync throttled - wait ${remainingSeconds}s") Logger.d(TAG, "⏳ onSave sync throttled - wait ${remainingSeconds}s")
return return
} }
// Update last sync time // Update last sync time
prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply() prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply()
// Trigger sync via WorkManager // Trigger sync via WorkManager
Logger.d(TAG, "📤 Triggering onSave sync") Logger.d(TAG, "📤 Triggering onSave sync")
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>() val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()

View File

@@ -8,6 +8,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -45,6 +46,8 @@ import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.java.KoinJavaComponent.inject
import kotlin.getValue
/** /**
* Main Activity with Jetpack Compose UI * Main Activity with Jetpack Compose UI
@@ -68,9 +71,8 @@ class ComposeMainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModel() private val viewModel: MainViewModel by viewModel()
private val prefs by lazy { private val storage: NotesStorage by inject(NotesStorage::class.java)
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
}
// Phase 3: Track if coming from editor to scroll to top // Phase 3: Track if coming from editor to scroll to top
private var cameFromEditor = false private var cameFromEditor = false
@@ -309,37 +311,8 @@ class ComposeMainActivity : ComponentActivity() {
* v1.4.1: Migrates existing checklists for backwards compatibility. * v1.4.1: Migrates existing checklists for backwards compatibility.
*/ */
private fun migrateChecklistsForBackwardsCompat() { private fun migrateChecklistsForBackwardsCompat() {
val migrationKey = "v1.4.1_checklist_migration_done" viewModel.migrateChecklistsForBackwardsCompat()
// Only run once
if (prefs.getBoolean(migrationKey, false)) {
return
}
val storage = NotesStorage(this)
val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true
}
if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) {
val updatedNote = note.copy(
syncStatus = SyncStatus.PENDING
)
storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
}
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
}
// Mark migration as done
prefs.edit().putBoolean(migrationKey, true).apply()
} }
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")

View File

@@ -4,12 +4,13 @@ import android.app.Application
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SortDirection import dev.dettmer.simplenotes.models.SortDirection
import dev.dettmer.simplenotes.models.SortOption import dev.dettmer.simplenotes.models.SortOption
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncProgress import dev.dettmer.simplenotes.sync.SyncProgress
import dev.dettmer.simplenotes.sync.SyncStateManager import dev.dettmer.simplenotes.sync.SyncStateManager
@@ -29,6 +30,8 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.inject
import kotlin.getValue
/** /**
* ViewModel for MainActivity Compose * ViewModel for MainActivity Compose
@@ -36,10 +39,7 @@ import kotlinx.coroutines.withContext
* *
* Manages notes list, sync state, and deletion with undo. * Manages notes list, sync state, and deletion with undo.
*/ */
class MainViewModel( class MainViewModel(application: Application) : AndroidViewModel(application) {
private val storage: NotesStorage,
private val prefs: SharedPreferences
) : ViewModel() {
companion object { companion object {
private const val TAG = "MainViewModel" private const val TAG = "MainViewModel"
@@ -47,6 +47,9 @@ class MainViewModel(
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp" private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
} }
private val storage: NotesStorage by inject(NotesStorage::class.java)
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Notes State // Notes State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -209,11 +212,7 @@ class MainViewModel(
private suspend fun loadNotesAsync() { private suspend fun loadNotesAsync() {
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val pendingIds = _pendingDeletions.value val pendingIds = _pendingDeletions.value
val filteredNotes = allNotes.filter { it.id !in pendingIds }.map { Note( val filteredNotes = allNotes.filter { it.id !in pendingIds }
id = it.id,
content = it.content,
) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// Phase 3: Detect if a new note was added at the top // Phase 3: Detect if a new note was added at the top
@@ -298,11 +297,11 @@ class MainViewModel(
/** /**
* Delete all selected notes * Delete all selected notes
*/ */
fun deleteSelectedNotes(deleteFromServer: Boolean) { fun deleteSelectedNotes(deleteFromServer: Boolean) = viewModelScope.launch {
val selectedIds = _selectedNotes.value.toList() val selectedIds = _selectedNotes.value.toList()
val selectedNotes = _notes.value.filter { it.id in selectedIds } val selectedNotes = _notes.value.filter { it.id in selectedIds }
if (selectedNotes.isEmpty()) return if (selectedNotes.isEmpty()) return@launch
// Add to pending deletions // Add to pending deletions
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet() _pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
@@ -357,7 +356,7 @@ class MainViewModel(
/** /**
* Undo deletion of multiple notes * Undo deletion of multiple notes
*/ */
private fun undoDeleteMultiple(notes: List<Note>) { private fun undoDeleteMultiple(notes: List<Note>) = viewModelScope.launch{
// Remove from pending deletions // Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet() _pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
@@ -409,7 +408,7 @@ class MainViewModel(
/** /**
* Confirm note deletion (from dialog or auto-delete) * Confirm note deletion (from dialog or auto-delete)
*/ */
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) { fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) = viewModelScope.launch{
// Add to pending deletions // Add to pending deletions
_pendingDeletions.value = _pendingDeletions.value + note.id _pendingDeletions.value = _pendingDeletions.value + note.id
@@ -453,7 +452,7 @@ class MainViewModel(
/** /**
* Undo note deletion * Undo note deletion
*/ */
fun undoDelete(note: Note) { fun undoDelete(note: Note) = viewModelScope.launch{
// Remove from pending deletions // Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - note.id _pendingDeletions.value = _pendingDeletions.value - note.id
@@ -833,4 +832,37 @@ class MainViewModel(
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://" return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
} }
fun migrateChecklistsForBackwardsCompat() = viewModelScope.launch{
val migrationKey = "v1.4.1_checklist_migration_done"
// Only run once
if (prefs.getBoolean(migrationKey, false)) {
return@launch
}
val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true
}
if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) {
val updatedNote = note.copy(
syncStatus = SyncStatus.PENDING
)
storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
}
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
}
// Mark migration as done
prefs.edit().putBoolean(migrationKey, true).apply()
}
} }

View File

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.settings
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
@@ -26,45 +27,47 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.inject
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import kotlin.getValue
/** /**
* ViewModel for Settings screens * ViewModel for Settings screens
* v1.5.0: Jetpack Compose Settings Redesign * v1.5.0: Jetpack Compose Settings Redesign
* *
* Manages all settings state and actions across the Settings navigation graph. * Manages all settings state and actions across the Settings navigation graph.
*/ */
@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*) @Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*)
class SettingsViewModel(application: Application) : AndroidViewModel(application) { class SettingsViewModel(application: Application) : AndroidViewModel(application) {
companion object { companion object {
private const val TAG = "SettingsViewModel" private const val TAG = "SettingsViewModel"
private const val CONNECTION_TIMEOUT_MS = 3000 private const val CONNECTION_TIMEOUT_MS = 3000
private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations
private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important) private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important)
} }
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val backupManager = BackupManager(application) val backupManager = BackupManager(application)
private val notesStorage = NotesStorage(application) // v1.7.0: For server change detection private val storage: NotesStorage by inject(NotesStorage::class.java)
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
// 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection // 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection
// This prevents false-positive "server changed" toasts during text input // This prevents false-positive "server changed" toasts during text input
private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: "" private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Server Settings State // Server Settings State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// v1.5.0 Fix: Initialize URL with protocol prefix if empty // v1.5.0 Fix: Initialize URL with protocol prefix if empty
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: "" private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
// 🌟 v1.6.0: Separate host from prefix for better UX // 🌟 v1.6.0: Separate host from prefix for better UX
// isHttps determines the prefix, serverHost is the editable part // isHttps determines the prefix, serverHost is the editable part
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://")) private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow() val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
// Extract host part (everything after http:// or https://) // Extract host part (everything after http:// or https://)
private fun extractHostFromUrl(url: String): String { private fun extractHostFromUrl(url: String): String {
return when { return when {
@@ -73,26 +76,26 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
else -> url else -> url
} }
} }
// 🌟 v1.6.0: Only the host part is editable (without protocol prefix) // 🌟 v1.6.0: Only the host part is editable (without protocol prefix)
private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl)) private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl))
val serverHost: StateFlow<String> = _serverHost.asStateFlow() val serverHost: StateFlow<String> = _serverHost.asStateFlow()
// 🌟 v1.6.0: Full URL for display purposes (computed from prefix + host) // 🌟 v1.6.0: Full URL for display purposes (computed from prefix + host)
val serverUrl: StateFlow<String> = combine(_isHttps, _serverHost) { https, host -> val serverUrl: StateFlow<String> = combine(_isHttps, _serverHost) { https, host ->
val prefix = if (https) "https://" else "http://" val prefix = if (https) "https://" else "http://"
if (host.isEmpty()) "" else prefix + host if (host.isEmpty()) "" else prefix + host
}.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl) }.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl)
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "") private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
val username: StateFlow<String> = _username.asStateFlow() val username: StateFlow<String> = _username.asStateFlow()
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "") private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
val password: StateFlow<String> = _password.asStateFlow() val password: StateFlow<String> = _password.asStateFlow()
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown) private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow() val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
// 🌟 v1.6.0: Offline Mode Toggle // 🌟 v1.6.0: Offline Mode Toggle
// Default: true for new users (no server), false for existing users (has server config) // Default: true for new users (no server), false for existing users (has server config)
private val _offlineMode = MutableStateFlow( private val _offlineMode = MutableStateFlow(
@@ -104,35 +107,35 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
) )
val offlineMode: StateFlow<Boolean> = _offlineMode.asStateFlow() val offlineMode: StateFlow<Boolean> = _offlineMode.asStateFlow()
private fun hasExistingServerConfig(): Boolean { private fun hasExistingServerConfig(): Boolean {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && return !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" && serverUrl != "http://" &&
serverUrl != "https://" serverUrl != "https://"
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Events (for Activity-level actions like dialogs, intents) // Events (for Activity-level actions like dialogs, intents)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _events = MutableSharedFlow<SettingsEvent>() private val _events = MutableSharedFlow<SettingsEvent>()
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow() val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Markdown Export Progress State // Markdown Export Progress State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _markdownExportProgress = MutableStateFlow<MarkdownExportProgress?>(null) private val _markdownExportProgress = MutableStateFlow<MarkdownExportProgress?>(null)
val markdownExportProgress: StateFlow<MarkdownExportProgress?> = _markdownExportProgress.asStateFlow() val markdownExportProgress: StateFlow<MarkdownExportProgress?> = _markdownExportProgress.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Sync Settings State // Sync Settings State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _autoSyncEnabled = MutableStateFlow(prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)) private val _autoSyncEnabled = MutableStateFlow(prefs.getBoolean(Constants.KEY_AUTO_SYNC, false))
val autoSyncEnabled: StateFlow<Boolean> = _autoSyncEnabled.asStateFlow() val autoSyncEnabled: StateFlow<Boolean> = _autoSyncEnabled.asStateFlow()
private val _syncInterval = MutableStateFlow( private val _syncInterval = MutableStateFlow(
prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES) prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
) )
@@ -149,82 +152,82 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE) prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
) )
val triggerOnSave: StateFlow<Boolean> = _triggerOnSave.asStateFlow() val triggerOnSave: StateFlow<Boolean> = _triggerOnSave.asStateFlow()
private val _triggerOnResume = MutableStateFlow( private val _triggerOnResume = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME) prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
) )
val triggerOnResume: StateFlow<Boolean> = _triggerOnResume.asStateFlow() val triggerOnResume: StateFlow<Boolean> = _triggerOnResume.asStateFlow()
private val _triggerWifiConnect = MutableStateFlow( private val _triggerWifiConnect = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT) prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
) )
val triggerWifiConnect: StateFlow<Boolean> = _triggerWifiConnect.asStateFlow() val triggerWifiConnect: StateFlow<Boolean> = _triggerWifiConnect.asStateFlow()
private val _triggerPeriodic = MutableStateFlow( private val _triggerPeriodic = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC) prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
) )
val triggerPeriodic: StateFlow<Boolean> = _triggerPeriodic.asStateFlow() val triggerPeriodic: StateFlow<Boolean> = _triggerPeriodic.asStateFlow()
private val _triggerBoot = MutableStateFlow( private val _triggerBoot = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT) prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
) )
val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow() val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow()
// 🎉 v1.7.0: WiFi-Only Sync Toggle // 🎉 v1.7.0: WiFi-Only Sync Toggle
private val _wifiOnlySync = MutableStateFlow( private val _wifiOnlySync = MutableStateFlow(
prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC) prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
) )
val wifiOnlySync: StateFlow<Boolean> = _wifiOnlySync.asStateFlow() val wifiOnlySync: StateFlow<Boolean> = _wifiOnlySync.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Markdown Settings State // Markdown Settings State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _markdownAutoSync = MutableStateFlow( private val _markdownAutoSync = MutableStateFlow(
prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) && prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) &&
prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false) prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
) )
val markdownAutoSync: StateFlow<Boolean> = _markdownAutoSync.asStateFlow() val markdownAutoSync: StateFlow<Boolean> = _markdownAutoSync.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Debug Settings State // Debug Settings State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _fileLoggingEnabled = MutableStateFlow( private val _fileLoggingEnabled = MutableStateFlow(
prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false) prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
) )
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow() val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Settings State // 🎨 v1.7.0: Display Settings State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _displayMode = MutableStateFlow( private val _displayMode = MutableStateFlow(
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
) )
val displayMode: StateFlow<String> = _displayMode.asStateFlow() val displayMode: StateFlow<String> = _displayMode.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// UI State // UI State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _isSyncing = MutableStateFlow(false) private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow() val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
private val _isBackupInProgress = MutableStateFlow(false) private val _isBackupInProgress = MutableStateFlow(false)
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow() val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow()
// v1.8.0: Descriptive backup status text // v1.8.0: Descriptive backup status text
private val _backupStatusText = MutableStateFlow("") private val _backupStatusText = MutableStateFlow("")
val backupStatusText: StateFlow<String> = _backupStatusText.asStateFlow() val backupStatusText: StateFlow<String> = _backupStatusText.asStateFlow()
private val _showToast = MutableSharedFlow<String>() private val _showToast = MutableSharedFlow<String>()
val showToast: SharedFlow<String> = _showToast.asSharedFlow() val showToast: SharedFlow<String> = _showToast.asSharedFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Server Settings Actions // Server Settings Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/** /**
* v1.6.0: Set offline mode on/off * v1.6.0: Set offline mode on/off
* When enabled, all network features are disabled * When enabled, all network features are disabled
@@ -232,7 +235,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun setOfflineMode(enabled: Boolean) { fun setOfflineMode(enabled: Boolean) {
_offlineMode.value = enabled _offlineMode.value = enabled
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply() prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply()
if (enabled) { if (enabled) {
_serverStatus.value = ServerStatus.OfflineMode _serverStatus.value = ServerStatus.OfflineMode
} else { } else {
@@ -240,14 +243,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
checkServerStatus() checkServerStatus()
} }
} }
fun updateServerUrl(url: String) { fun updateServerUrl(url: String) {
// 🌟 v1.6.0: Deprecated - use updateServerHost instead // 🌟 v1.6.0: Deprecated - use updateServerHost instead
// This function is kept for compatibility but now delegates to updateServerHost // This function is kept for compatibility but now delegates to updateServerHost
val host = extractHostFromUrl(url) val host = extractHostFromUrl(url)
updateServerHost(host) updateServerHost(host)
} }
/** /**
* 🌟 v1.6.0: Update only the host part of the server URL * 🌟 v1.6.0: Update only the host part of the server URL
* The protocol prefix is handled separately by updateProtocol() * The protocol prefix is handled separately by updateProtocol()
@@ -257,37 +260,37 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
*/ */
fun updateServerHost(host: String) { fun updateServerHost(host: String) {
_serverHost.value = host _serverHost.value = host
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection // ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
val prefix = if (_isHttps.value) "https://" else "http://" val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (host.isEmpty()) "" else prefix + host val fullUrl = if (host.isEmpty()) "" else prefix + host
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply() prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
} }
fun updateProtocol(useHttps: Boolean) { fun updateProtocol(useHttps: Boolean) {
_isHttps.value = useHttps _isHttps.value = useHttps
// 🌟 v1.6.0: Host stays the same, only prefix changes // 🌟 v1.6.0: Host stays the same, only prefix changes
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection // 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService) // 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection // ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
val prefix = if (useHttps) "https://" else "http://" val prefix = if (useHttps) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply() prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
} }
fun updateUsername(value: String) { fun updateUsername(value: String) {
_username.value = value _username.value = value
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService) // 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_USERNAME, value).apply() prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
} }
fun updatePassword(value: String) { fun updatePassword(value: String) {
_password.value = value _password.value = value
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService) // 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply() prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
} }
/** /**
* 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen * 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
* This prevents false "server changed" detection during text input * This prevents false "server changed" detection during text input
@@ -298,17 +301,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// 🌟 v1.6.0: Construct full URL from prefix + host // 🌟 v1.6.0: Construct full URL from prefix + host
val prefix = if (_isHttps.value) "https://" else "http://" val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
// 🔄 v1.7.0: Detect server change ONLY against last confirmed URL // 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl) val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
// ✅ Settings are already saved in updateServerHost/Protocol/Username/Password // ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
// This function now ONLY handles server-change detection // This function now ONLY handles server-change detection
// Reset sync status if server actually changed // Reset sync status if server actually changed
if (serverChanged) { if (serverChanged) {
viewModelScope.launch { viewModelScope.launch {
val count = notesStorage.resetAllSyncStatusToPending() val count = storage.resetAllSyncStatusToPending()
Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING") Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
emitToast(getString(R.string.toast_server_changed_sync_reset, count)) emitToast(getString(R.string.toast_server_changed_sync_reset, count))
} }
@@ -318,10 +321,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
Logger.d(TAG, "💾 Server settings check complete (no server change detected)") Logger.d(TAG, "💾 Server settings check complete (no server change detected)")
} }
} }
/** /**
* <20> v1.7.0 Hotfix: Improved server change detection * <20> v1.7.0 Hotfix: Improved server change detection
* *
* Only returns true if the server URL actually changed in a meaningful way. * Only returns true if the server URL actually changed in a meaningful way.
* Handles edge cases: * Handles edge cases:
* - First setup (empty → filled) = NOT a change * - First setup (empty → filled) = NOT a change
@@ -336,23 +339,23 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
Logger.d(TAG, "First server setup detected (no reset needed)") Logger.d(TAG, "First server setup detected (no reset needed)")
return false return false
} }
// Both empty = No change // Both empty = No change
if (confirmedUrl.isEmpty() && newUrl.isEmpty()) { if (confirmedUrl.isEmpty() && newUrl.isEmpty()) {
return false return false
} }
// Non-empty → Empty = Server removed (keep notes local, no reset) // Non-empty → Empty = Server removed (keep notes local, no reset)
if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) { if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) {
Logger.d(TAG, "Server removed (notes stay local, no reset needed)") Logger.d(TAG, "Server removed (notes stay local, no reset needed)")
return false return false
} }
// Same URL = No change // Same URL = No change
if (confirmedUrl == newUrl) { if (confirmedUrl == newUrl) {
return false return false
} }
// Normalize URLs for comparison (ignore protocol, trailing slash, case) // Normalize URLs for comparison (ignore protocol, trailing slash, case)
val normalize = { url: String -> val normalize = { url: String ->
url.trim() url.trim()
@@ -361,20 +364,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.removeSuffix("/") .removeSuffix("/")
.lowercase() .lowercase()
} }
val confirmedNormalized = normalize(confirmedUrl) val confirmedNormalized = normalize(confirmedUrl)
val newNormalized = normalize(newUrl) val newNormalized = normalize(newUrl)
// Check if normalized URLs differ // Check if normalized URLs differ
val changed = confirmedNormalized != newNormalized val changed = confirmedNormalized != newNormalized
if (changed) { if (changed) {
Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'") Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'")
} }
return changed return changed
} }
fun testConnection() { fun testConnection() {
viewModelScope.launch { viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking _serverStatus.value = ServerStatus.Checking
@@ -398,25 +401,25 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
fun checkServerStatus() { fun checkServerStatus() {
// 🌟 v1.6.0: Respect offline mode first // 🌟 v1.6.0: Respect offline mode first
if (_offlineMode.value) { if (_offlineMode.value) {
_serverStatus.value = ServerStatus.OfflineMode _serverStatus.value = ServerStatus.OfflineMode
return return
} }
// 🌟 v1.6.0: Check if host is configured // 🌟 v1.6.0: Check if host is configured
val serverHost = _serverHost.value val serverHost = _serverHost.value
if (serverHost.isEmpty()) { if (serverHost.isEmpty()) {
_serverStatus.value = ServerStatus.NotConfigured _serverStatus.value = ServerStatus.NotConfigured
return return
} }
// Construct full URL // Construct full URL
val prefix = if (_isHttps.value) "https://" else "http://" val prefix = if (_isHttps.value) "https://" else "http://"
val serverUrl = prefix + serverHost val serverUrl = prefix + serverHost
viewModelScope.launch { viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking _serverStatus.value = ServerStatus.Checking
val isReachable = withContext(Dispatchers.IO) { val isReachable = withContext(Dispatchers.IO) {
@@ -436,14 +439,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
_serverStatus.value = if (isReachable) ServerStatus.Reachable else ServerStatus.Unreachable(null) _serverStatus.value = if (isReachable) ServerStatus.Reachable else ServerStatus.Unreachable(null)
} }
} }
fun syncNow() { fun syncNow() {
if (_isSyncing.value) return if (_isSyncing.value) return
viewModelScope.launch { viewModelScope.launch {
_isSyncing.value = true _isSyncing.value = true
try { try {
val syncService = WebDavSyncService(getApplication()) val syncService = WebDavSyncService(getApplication())
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
val gateResult = syncService.canSync() val gateResult = syncService.canSync()
if (!gateResult.canSync) { if (!gateResult.canSync) {
@@ -454,14 +457,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
return@launch return@launch
} }
emitToast(getString(R.string.toast_syncing)) emitToast(getString(R.string.toast_syncing))
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
emitToast(getString(R.string.toast_already_synced)) emitToast(getString(R.string.toast_already_synced))
return@launch return@launch
} }
val result = syncService.syncNotes() val result = syncService.syncNotes()
if (result.isSuccess) { if (result.isSuccess) {
emitToast(getString(R.string.toast_sync_success, result.syncedCount)) emitToast(getString(R.string.toast_sync_success, result.syncedCount))
@@ -475,15 +478,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Sync Settings Actions // Sync Settings Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
fun setAutoSync(enabled: Boolean) { fun setAutoSync(enabled: Boolean) {
_autoSyncEnabled.value = enabled _autoSyncEnabled.value = enabled
prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply() prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply()
viewModelScope.launch { viewModelScope.launch {
if (enabled) { if (enabled) {
// v1.5.0 Fix: Trigger battery optimization check and network monitor restart // v1.5.0 Fix: Trigger battery optimization check and network monitor restart
@@ -496,7 +499,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
fun setSyncInterval(minutes: Long) { fun setSyncInterval(minutes: Long) {
_syncInterval.value = minutes _syncInterval.value = minutes
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, minutes).apply() prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, minutes).apply()
@@ -521,19 +524,19 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
// 🌟 v1.6.0: Configurable Sync Triggers Setters // 🌟 v1.6.0: Configurable Sync Triggers Setters
fun setTriggerOnSave(enabled: Boolean) { fun setTriggerOnSave(enabled: Boolean) {
_triggerOnSave.value = enabled _triggerOnSave.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply() prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply()
Logger.d(TAG, "Trigger onSave: $enabled") Logger.d(TAG, "Trigger onSave: $enabled")
} }
fun setTriggerOnResume(enabled: Boolean) { fun setTriggerOnResume(enabled: Boolean) {
_triggerOnResume.value = enabled _triggerOnResume.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply() prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply()
Logger.d(TAG, "Trigger onResume: $enabled") Logger.d(TAG, "Trigger onResume: $enabled")
} }
fun setTriggerWifiConnect(enabled: Boolean) { fun setTriggerWifiConnect(enabled: Boolean) {
_triggerWifiConnect.value = enabled _triggerWifiConnect.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply() prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply()
@@ -542,7 +545,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
Logger.d(TAG, "Trigger WiFi-Connect: $enabled") Logger.d(TAG, "Trigger WiFi-Connect: $enabled")
} }
fun setTriggerPeriodic(enabled: Boolean) { fun setTriggerPeriodic(enabled: Boolean) {
_triggerPeriodic.value = enabled _triggerPeriodic.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply() prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply()
@@ -551,13 +554,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
Logger.d(TAG, "Trigger Periodic: $enabled") Logger.d(TAG, "Trigger Periodic: $enabled")
} }
fun setTriggerBoot(enabled: Boolean) { fun setTriggerBoot(enabled: Boolean) {
_triggerBoot.value = enabled _triggerBoot.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply() prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply()
Logger.d(TAG, "Trigger Boot: $enabled") Logger.d(TAG, "Trigger Boot: $enabled")
} }
/** /**
* 🎉 v1.7.0: Set WiFi-only sync mode * 🎉 v1.7.0: Set WiFi-only sync mode
* When enabled, sync only happens when connected to WiFi * When enabled, sync only happens when connected to WiFi
@@ -567,11 +570,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply() prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply()
Logger.d(TAG, "📡 WiFi-only sync: $enabled") Logger.d(TAG, "📡 WiFi-only sync: $enabled")
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Markdown Settings Actions // Markdown Settings Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
fun setMarkdownAutoSync(enabled: Boolean) { fun setMarkdownAutoSync(enabled: Boolean) {
if (enabled) { if (enabled) {
// v1.5.0 Fix: Perform initial export when enabling (like old SettingsActivity) // v1.5.0 Fix: Perform initial export when enabling (like old SettingsActivity)
@@ -581,21 +584,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: "" val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: "" val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: "" val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) { if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
emitToast(getString(R.string.toast_configure_server_first)) emitToast(getString(R.string.toast_configure_server_first))
// Don't enable - revert state // Don't enable - revert state
return@launch return@launch
} }
// Check if there are notes to export // Check if there are notes to export
val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(getApplication()) val noteCount = storage.loadAllNotes().size
val noteCount = noteStorage.loadAllNotes().size
if (noteCount > 0) { if (noteCount > 0) {
// Show progress and perform initial export // Show progress and perform initial export
_markdownExportProgress.value = MarkdownExportProgress(0, noteCount) _markdownExportProgress.value = MarkdownExportProgress(0, noteCount)
val syncService = WebDavSyncService(getApplication()) val syncService = WebDavSyncService(getApplication())
val exportedCount = withContext(Dispatchers.IO) { val exportedCount = withContext(Dispatchers.IO) {
syncService.exportAllNotesToMarkdown( syncService.exportAllNotesToMarkdown(
@@ -607,22 +609,22 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
) )
} }
// Export successful - save settings // Export successful - save settings
_markdownAutoSync.value = true _markdownAutoSync.value = true
prefs.edit() prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true) .putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true) .putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
.apply() .apply()
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true) _markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
emitToast(getString(R.string.toast_markdown_exported, exportedCount)) emitToast(getString(R.string.toast_markdown_exported, exportedCount))
@Suppress("MagicNumber") // UI progress delay @Suppress("MagicNumber") // UI progress delay
// Clear progress after short delay // Clear progress after short delay
kotlinx.coroutines.delay(500) kotlinx.coroutines.delay(500)
_markdownExportProgress.value = null _markdownExportProgress.value = null
} else { } else {
// No notes - just enable the feature // No notes - just enable the feature
_markdownAutoSync.value = true _markdownAutoSync.value = true
@@ -632,7 +634,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.apply() .apply()
emitToast(getString(R.string.toast_markdown_enabled)) emitToast(getString(R.string.toast_markdown_enabled))
} }
} catch (e: Exception) { } catch (e: Exception) {
_markdownExportProgress.value = null _markdownExportProgress.value = null
emitToast(getString(R.string.toast_export_failed, e.message ?: "")) emitToast(getString(R.string.toast_export_failed, e.message ?: ""))
@@ -651,14 +653,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
fun performManualMarkdownSync() { fun performManualMarkdownSync() {
// 🌟 v1.6.0: Block in offline mode // 🌟 v1.6.0: Block in offline mode
if (_offlineMode.value) { if (_offlineMode.value) {
Logger.d(TAG, "⏭️ Manual Markdown sync blocked: Offline mode enabled") Logger.d(TAG, "⏭️ Manual Markdown sync blocked: Offline mode enabled")
return return
} }
viewModelScope.launch { viewModelScope.launch {
try { try {
emitToast(getString(R.string.toast_markdown_syncing)) emitToast(getString(R.string.toast_markdown_syncing))
@@ -670,28 +672,28 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Backup Actions // Backup Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
fun createBackup(uri: Uri, password: String? = null) { fun createBackup(uri: Uri, password: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_creating) _backupStatusText.value = getString(R.string.backup_progress_creating)
try { try {
val result = backupManager.createBackup(uri, password) val result = backupManager.createBackup(uri, password)
// Phase 2: Show completion status // Phase 2: Show completion status
_backupStatusText.value = if (result.success) { _backupStatusText.value = if (result.success) {
getString(R.string.backup_progress_complete) getString(R.string.backup_progress_complete)
} else { } else {
getString(R.string.backup_progress_failed) getString(R.string.backup_progress_failed)
} }
// Phase 3: Clear after delay // Phase 3: Clear after delay
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS) delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to create backup", e) Logger.e(TAG, "Failed to create backup", e)
_backupStatusText.value = getString(R.string.backup_progress_failed) _backupStatusText.value = getString(R.string.backup_progress_failed)
@@ -702,24 +704,24 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) { fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_restoring) _backupStatusText.value = getString(R.string.backup_progress_restoring)
try { try {
val result = backupManager.restoreBackup(uri, mode, password) val result = backupManager.restoreBackup(uri, mode, password)
// Phase 2: Show completion status // Phase 2: Show completion status
_backupStatusText.value = if (result.success) { _backupStatusText.value = if (result.success) {
getString(R.string.restore_progress_complete) getString(R.string.restore_progress_complete)
} else { } else {
getString(R.string.restore_progress_failed) getString(R.string.restore_progress_failed)
} }
// Phase 3: Clear after delay // Phase 3: Clear after delay
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS) delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to restore backup from file", e) Logger.e(TAG, "Failed to restore backup from file", e)
_backupStatusText.value = getString(R.string.restore_progress_failed) _backupStatusText.value = getString(R.string.restore_progress_failed)
@@ -730,7 +732,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
/** /**
* 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback * 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback
*/ */
@@ -753,7 +755,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
fun restoreFromServer(mode: RestoreMode) { fun restoreFromServer(mode: RestoreMode) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
@@ -763,17 +765,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.restoreFromServer(mode) syncService.restoreFromServer(mode)
} }
// Phase 2: Show completion status // Phase 2: Show completion status
_backupStatusText.value = if (result.isSuccess) { _backupStatusText.value = if (result.isSuccess) {
getString(R.string.restore_server_progress_complete) getString(R.string.restore_server_progress_complete)
} else { } else {
getString(R.string.restore_server_progress_failed) getString(R.string.restore_server_progress_failed)
} }
// Phase 3: Clear after delay // Phase 3: Clear after delay
delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS) delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to restore from server", e) Logger.e(TAG, "Failed to restore from server", e)
_backupStatusText.value = getString(R.string.restore_server_progress_failed) _backupStatusText.value = getString(R.string.restore_server_progress_failed)
@@ -784,11 +786,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Debug Settings Actions // Debug Settings Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
fun setFileLogging(enabled: Boolean) { fun setFileLogging(enabled: Boolean) {
_fileLoggingEnabled.value = enabled _fileLoggingEnabled.value = enabled
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, enabled).apply() prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, enabled).apply()
@@ -797,7 +799,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
emitToast(if (enabled) getString(R.string.toast_file_logging_enabled) else getString(R.string.toast_file_logging_disabled)) emitToast(if (enabled) getString(R.string.toast_file_logging_enabled) else getString(R.string.toast_file_logging_disabled))
} }
} }
fun clearLogs() { fun clearLogs() {
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -808,9 +810,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
fun getLogFile() = Logger.getLogFile(getApplication()) fun getLogFile() = Logger.getLogFile(getApplication())
/** /**
* v1.8.0: Reset changelog version to force showing the changelog dialog on next start * v1.8.0: Reset changelog version to force showing the changelog dialog on next start
* Used for testing the post-update changelog feature * Used for testing the post-update changelog feature
@@ -820,11 +822,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0) .putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
.apply() .apply()
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Helper // Helper
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/** /**
* Check if server is configured AND not in offline mode * Check if server is configured AND not in offline mode
* v1.6.0: Returns false if offline mode is enabled * v1.6.0: Returns false if offline mode is enabled
@@ -832,16 +834,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun isServerConfigured(): Boolean { fun isServerConfigured(): Boolean {
// Offline mode takes priority // Offline mode takes priority
if (_offlineMode.value) return false if (_offlineMode.value) return false
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && return !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" && serverUrl != "http://" &&
serverUrl != "https://" serverUrl != "https://"
} }
/** /**
* 🌍 v1.7.1: Get string resources with correct app locale * 🌍 v1.7.1: Get string resources with correct app locale
* *
* AndroidViewModel uses Application context which may not have the correct locale * AndroidViewModel uses Application context which may not have the correct locale
* applied when using per-app language settings. We need to get a Context that * applied when using per-app language settings. We need to get a Context that
* respects AppCompatDelegate.getApplicationLocales(). * respects AppCompatDelegate.getApplicationLocales().
@@ -860,7 +862,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
return context.getString(resId) return context.getString(resId)
} }
private fun getString(resId: Int, vararg formatArgs: Any): String { private fun getString(resId: Int, vararg formatArgs: Any): String {
// Get context with correct locale configuration from AppCompatDelegate // Get context with correct locale configuration from AppCompatDelegate
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales() val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
@@ -875,11 +877,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
return context.getString(resId, *formatArgs) return context.getString(resId, *formatArgs)
} }
private suspend fun emitToast(message: String) { private suspend fun emitToast(message: String) {
_showToast.emit(message) _showToast.emit(message)
} }
/** /**
* Server status states * Server status states
* v1.6.0: Added OfflineMode state * v1.6.0: Added OfflineMode state
@@ -892,7 +894,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
data object Reachable : ServerStatus() data object Reachable : ServerStatus()
data class Unreachable(val error: String?) : ServerStatus() data class Unreachable(val error: String?) : ServerStatus()
} }
/** /**
* Events for Activity-level actions (dialogs, intents, etc.) * Events for Activity-level actions (dialogs, intents, etc.)
* v1.5.0: Ported from old SettingsActivity * v1.5.0: Ported from old SettingsActivity
@@ -901,7 +903,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
data object RequestBatteryOptimization : SettingsEvent() data object RequestBatteryOptimization : SettingsEvent()
data object RestartNetworkMonitor : SettingsEvent() data object RestartNetworkMonitor : SettingsEvent()
} }
/** /**
* Progress state for Markdown export * Progress state for Markdown export
* v1.5.0: For initial export progress dialog * v1.5.0: For initial export progress dialog
@@ -911,11 +913,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val total: Int, val total: Int,
val isComplete: Boolean = false val isComplete: Boolean = false
) )
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Mode Functions // 🎨 v1.7.0: Display Mode Functions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/** /**
* Set display mode (list or grid) * Set display mode (list or grid)
*/ */

View File

@@ -1,6 +1,7 @@
package dev.dettmer.simplenotes.widget package dev.dettmer.simplenotes.widget
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
@@ -12,6 +13,9 @@ import androidx.glance.appwidget.provideContent
import androidx.glance.currentState import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import kotlinx.coroutines.runBlocking
import org.koin.java.KoinJavaComponent.inject
import kotlin.getValue
/** /**
* 🆕 v1.8.0: Homescreen Widget für Notizen und Checklisten * 🆕 v1.8.0: Homescreen Widget für Notizen und Checklisten
@@ -52,10 +56,11 @@ class NoteWidget : GlanceAppWidget() {
) )
) )
private val storage: NotesStorage by inject(NotesStorage::class.java)
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val storage = NotesStorage(context)
provideContent { provideContent {
val prefs = currentState<Preferences>() val prefs = currentState<Preferences>()
@@ -65,7 +70,7 @@ class NoteWidget : GlanceAppWidget() {
val bgOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f val bgOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f
val note = noteId?.let { nId -> val note = noteId?.let { nId ->
storage.loadNote(nId) runBlocking { storage.loadNote(nId) }
} }
GlanceTheme { GlanceTheme {

View File

@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import org.koin.java.KoinJavaComponent.inject
/** /**
* 🆕 v1.8.0: ActionParameter Keys für Widget-Interaktionen * 🆕 v1.8.0: ActionParameter Keys für Widget-Interaktionen
@@ -35,6 +36,9 @@ class ToggleChecklistItemAction : ActionCallback {
private const val TAG = "ToggleChecklistItem" private const val TAG = "ToggleChecklistItem"
} }
private val storage: NotesStorage by inject(NotesStorage::class.java)
override suspend fun onAction( override suspend fun onAction(
context: Context, context: Context,
glanceId: GlanceId, glanceId: GlanceId,
@@ -43,7 +47,6 @@ class ToggleChecklistItemAction : ActionCallback {
val noteId = parameters[NoteWidgetActionKeys.KEY_NOTE_ID] ?: return val noteId = parameters[NoteWidgetActionKeys.KEY_NOTE_ID] ?: return
val itemId = parameters[NoteWidgetActionKeys.KEY_ITEM_ID] ?: return val itemId = parameters[NoteWidgetActionKeys.KEY_ITEM_ID] ?: return
val storage = NotesStorage(context)
val note = storage.loadNote(noteId) ?: return val note = storage.loadNote(noteId) ?: return
val updatedItems = note.checklistItems?.map { item -> val updatedItems = note.checklistItems?.map { item ->
@@ -167,11 +170,11 @@ class OpenConfigAction : ActionCallback {
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
} }
// Config-Activity als Reconfigure öffnen // Config-Activity als Reconfigure öffnen
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(context) val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(context)
val appWidgetId = glanceManager.getAppWidgetId(glanceId) val appWidgetId = glanceManager.getAppWidgetId(glanceId)
val intent = android.content.Intent(context, NoteWidgetConfigActivity::class.java).apply { val intent = android.content.Intent(context, NoteWidgetConfigActivity::class.java).apply {
putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
// 🐛 FIX: Eigener Task, damit finish() nicht die MainActivity zeigt // 🐛 FIX: Eigener Task, damit finish() nicht die MainActivity zeigt

View File

@@ -14,6 +14,8 @@ import androidx.lifecycle.lifecycleScope
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import kotlin.getValue
/** /**
* 🆕 v1.8.0: Konfigurations-Activity beim Platzieren eines Widgets * 🆕 v1.8.0: Konfigurations-Activity beim Platzieren eines Widgets
@@ -40,6 +42,8 @@ class NoteWidgetConfigActivity : ComponentActivity() {
private var currentLockState: Boolean = false private var currentLockState: Boolean = false
private var currentOpacity: Float = 1.0f private var currentOpacity: Float = 1.0f
private val storage: NotesStorage by inject(NotesStorage::class.java)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -69,13 +73,12 @@ class NoteWidgetConfigActivity : ComponentActivity() {
return return
} }
val storage = NotesStorage(this)
// Bestehende Konfiguration laden (für Reconfigure) // Bestehende Konfiguration laden (für Reconfigure)
lifecycleScope.launch { lifecycleScope.launch {
var existingNoteId: String? = null var existingNoteId: String? = null
var existingLock = false var existingLock = false
var existingOpacity = 1.0f var existingOpacity = 1.0f
val notes = storage.loadAllNotes()
try { try {
val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity) val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity)
@@ -100,7 +103,7 @@ class NoteWidgetConfigActivity : ComponentActivity() {
setContent { setContent {
SimpleNotesTheme { SimpleNotesTheme {
NoteWidgetConfigScreen( NoteWidgetConfigScreen(
storage = storage, notes = notes,
initialLock = existingLock, initialLock = existingLock,
initialOpacity = existingOpacity, initialOpacity = existingOpacity,
selectedNoteId = existingNoteId, selectedNoteId = existingNoteId,
@@ -145,7 +148,7 @@ class NoteWidgetConfigActivity : ComponentActivity() {
AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId
) )
setResult(RESULT_OK, resultIntent) setResult(RESULT_OK, resultIntent)
// 🐛 FIX: Zurück zum Homescreen statt zur MainActivity // 🐛 FIX: Zurück zum Homescreen statt zur MainActivity
// moveTaskToBack() bringt den Task in den Hintergrund → Homescreen wird sichtbar // moveTaskToBack() bringt den Task in den Hintergrund → Homescreen wird sichtbar
if (!isTaskRoot) { if (!isTaskRoot) {

View File

@@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.storage.NotesStorage
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -59,16 +58,16 @@ private const val NOTE_PREVIEW_MAX_LENGTH = 50
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NoteWidgetConfigScreen( fun NoteWidgetConfigScreen(
storage: NotesStorage, notes: List<Note>,
initialLock: Boolean = false, initialLock: Boolean = false,
initialOpacity: Float = 1.0f, initialOpacity: Float = 1.0f,
selectedNoteId: String? = null, selectedNoteId: String? = null,
onNoteSelected: (noteId: String, isLocked: Boolean, opacity: Float) -> Unit, onNoteSelected: (String, Boolean, Float) -> Unit,
onSave: ((noteId: String, isLocked: Boolean, opacity: Float) -> Unit)? = null, onSave: ((String, Boolean, Float) -> Unit)? = null,
onSettingsChanged: ((noteId: String?, isLocked: Boolean, opacity: Float) -> Unit)? = null, onSettingsChanged: ((String?, Boolean, Float) -> Unit)? = null,
@Suppress("UNUSED_PARAMETER") onCancel: () -> Unit // Reserved for future use @Suppress("UNUSED_PARAMETER") onCancel: () -> Unit // Reserved for future use
) { ) {
val allNotes = remember { storage.loadAllNotes().sortedByDescending { it.updatedAt } } val allNotes = remember { notes.sortedByDescending { it.updatedAt } }
var lockWidget by remember { mutableStateOf(initialLock) } var lockWidget by remember { mutableStateOf(initialLock) }
var opacity by remember { mutableFloatStateOf(initialOpacity) } var opacity by remember { mutableFloatStateOf(initialOpacity) }
var currentSelectedId by remember { mutableStateOf(selectedNoteId) } var currentSelectedId by remember { mutableStateOf(selectedNoteId) }

View File

@@ -17,7 +17,7 @@ navigationCompose = "2.7.6"
lifecycleRuntimeCompose = "2.7.0" lifecycleRuntimeCompose = "2.7.0"
activityCompose = "1.8.2" activityCompose = "1.8.2"
room = "2.6.1" room = "2.6.1"
ksp = "2.0.0-1.0.21" ksp = "2.0.21-1.0.27"
koin = "3.5.3" koin = "3.5.3"
[libraries] [libraries]