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
27 changed files with 1440 additions and 2068 deletions

View File

@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
alias(libs.plugins.detekt) alias(libs.plugins.detekt)
alias(libs.plugins.ksp)
} }
import java.util.Properties import java.util.Properties
@@ -25,13 +26,13 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility // Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
dependenciesInfo { dependenciesInfo {
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
includeInBundle = false // Also disable for AAB (Google Play) includeInBundle = false // Also disable for AAB (Google Play)
} }
// Product Flavors for F-Droid and standard builds // Product Flavors for F-Droid and standard builds
// Note: APK splits are disabled to ensure single APK output // Note: APK splits are disabled to ensure single APK output
flavorDimensions += "distribution" flavorDimensions += "distribution"
@@ -42,7 +43,7 @@ android {
// All dependencies in this project are already FOSS-compatible // All dependencies in this project are already FOSS-compatible
// No APK splits - F-Droid expects single universal APK // No APK splits - F-Droid expects single universal APK
} }
create("standard") { create("standard") {
dimension = "distribution" dimension = "distribution"
// Standard builds can include Play Services in the future if needed // Standard builds can include Play Services in the future if needed
@@ -57,7 +58,7 @@ android {
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
val keystoreProperties = Properties() val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile)) keystoreProperties.load(FileInputStream(keystorePropertiesFile))
storeFile = file(keystoreProperties.getProperty("storeFile")) storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword") storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias") keyAlias = keystoreProperties.getProperty("keyAlias")
@@ -72,11 +73,11 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
versionNameSuffix = "-debug" versionNameSuffix = "-debug"
isDebuggable = true isDebuggable = true
// Optionales separates Icon-Label für Debug-Builds // Optionales separates Icon-Label für Debug-Builds
resValue("string", "app_name_debug", "Simple Notes (Debug)") resValue("string", "app_name_debug", "Simple Notes (Debug)")
} }
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -98,12 +99,12 @@ android {
buildConfig = true // Enable BuildConfig generation buildConfig = true // Enable BuildConfig generation
compose = true // v1.5.0: Jetpack Compose für Settings Redesign compose = true // v1.5.0: Jetpack Compose für Settings Redesign
} }
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.) // v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
} }
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance // v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard // v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
// composeCompiler { } // composeCompiler { }
@@ -162,6 +163,15 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
// Koin
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Homescreen Widgets // 🆕 v1.8.0: Homescreen Widgets
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -180,7 +190,7 @@ ktlint {
outputToConsole = true outputToConsole = true
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
enableExperimentalRules = false enableExperimentalRules = false
filter { filter {
exclude("**/generated/**") exclude("**/generated/**")
exclude("**/build/**") exclude("**/build/**")
@@ -196,7 +206,7 @@ detekt {
allRules = false allRules = false
config.setFrom(files("$rootDir/config/detekt/detekt.yml")) config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
baseline = file("$rootDir/config/detekt/baseline.xml") baseline = file("$rootDir/config/detekt/baseline.xml")
// Parallel-Verarbeitung für schnellere Checks // Parallel-Verarbeitung für schnellere Checks
parallel = true parallel = true
} }
@@ -205,13 +215,13 @@ detekt {
// Single source of truth: F-Droid changelogs are reused in the app // Single source of truth: F-Droid changelogs are reused in the app
tasks.register<Copy>("copyChangelogsToAssets") { tasks.register<Copy>("copyChangelogsToAssets") {
description = "Copies F-Droid changelogs to app assets for post-update dialog" description = "Copies F-Droid changelogs to app assets for post-update dialog"
from("$rootDir/../fastlane/metadata/android") { from("$rootDir/../fastlane/metadata/android") {
include("*/changelogs/*.txt") include("*/changelogs/*.txt")
} }
into("$projectDir/src/main/assets/changelogs") into("$projectDir/src/main/assets/changelogs")
// Preserve directory structure: en-US/20.txt, de-DE/20.txt // Preserve directory structure: en-US/20.txt, de-DE/20.txt
eachFile { eachFile {
val parts = relativePath.segments val parts = relativePath.segments
@@ -222,11 +232,11 @@ tasks.register<Copy>("copyChangelogsToAssets") {
relativePath = RelativePath(true, parts[0], parts[2]) relativePath = RelativePath(true, parts[0], parts[2])
} }
} }
includeEmptyDirs = false includeEmptyDirs = false
} }
// Run before preBuild to ensure changelogs are available // Run before preBuild to ensure changelogs are available
tasks.named("preBuild") { tasks.named("preBuild") {
dependsOn("copyChangelogsToAssets") dependsOn("copyChangelogsToAssets")
} }

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,856 +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 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

@@ -6,18 +6,22 @@ import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.di.appModule
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
class SimpleNotesApplication : Application() { class SimpleNotesApplication : Application() {
companion object { companion object {
private const val TAG = "SimpleNotesApp" private const val TAG = "SimpleNotesApp"
} }
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
/** /**
* 🌍 v1.7.1: Apply app locale to Application Context * 🌍 v1.7.1: Apply app locale to Application Context
* *
* This ensures ViewModels and other components using Application Context * This ensures ViewModels and other components using Application Context
* get the correct locale-specific strings. * get the correct locale-specific strings.
*/ */
@@ -26,71 +30,77 @@ class SimpleNotesApplication : Application() {
// This is handled by AppCompatDelegate which reads from system storage // This is handled by AppCompatDelegate which reads from system storage
super.attachBaseContext(base) super.attachBaseContext(base)
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
startKoin {
androidLogger() // Log Koin events
androidContext(this@SimpleNotesApplication) // Provide context to modules
modules(appModule)
}
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization // 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly // This prevents the offline mode bug where users updating from v1.5.0 incorrectly
// appear as offline even though they have a configured server // appear as offline even though they have a configured server
migrateOfflineModeSetting(prefs) migrateOfflineModeSetting(prefs)
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!) // File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
if (prefs.getBoolean("file_logging_enabled", false)) { if (prefs.getBoolean("file_logging_enabled", false)) {
Logger.enableFileLogging(this) Logger.enableFileLogging(this)
Logger.d(TAG, "📝 File logging enabled at Application startup") Logger.d(TAG, "📝 File logging enabled at Application startup")
} }
Logger.d(TAG, "🚀 Application onCreate()") Logger.d(TAG, "🚀 Application onCreate()")
// Initialize notification channel // Initialize notification channel
NotificationHelper.createNotificationChannel(this) NotificationHelper.createNotificationChannel(this)
Logger.d(TAG, "✅ Notification channel created") Logger.d(TAG, "✅ Notification channel created")
// Initialize NetworkMonitor (WorkManager-based) // Initialize NetworkMonitor (WorkManager-based)
// VORTEIL: WorkManager läuft auch ohne aktive App! // VORTEIL: WorkManager läuft auch ohne aktive App!
networkMonitor = NetworkMonitor(applicationContext) networkMonitor = NetworkMonitor(applicationContext)
// Start WorkManager periodic sync // Start WorkManager periodic sync
// Dies läuft im Hintergrund auch wenn App geschlossen ist // Dies läuft im Hintergrund auch wenn App geschlossen ist
networkMonitor.startMonitoring() networkMonitor.startMonitoring()
Logger.d(TAG, "✅ WorkManager-based auto-sync initialized") Logger.d(TAG, "✅ WorkManager-based auto-sync initialized")
} }
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
Logger.d(TAG, "🛑 Application onTerminate()") Logger.d(TAG, "🛑 Application onTerminate()")
// WorkManager läuft weiter auch nach onTerminate! // WorkManager läuft weiter auch nach onTerminate!
// Nur bei deaktiviertem Auto-Sync stoppen wir es // Nur bei deaktiviertem Auto-Sync stoppen wir es
} }
/** /**
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0 * 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
* *
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel * Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
* and NoteEditorViewModel use `true` as default, causing existing users * and NoteEditorViewModel use `true` as default, causing existing users
* with configured servers to appear in offline mode after update. * with configured servers to appear in offline mode after update.
* *
* Fix: Set the key BEFORE any ViewModel is initialized based on whether * Fix: Set the key BEFORE any ViewModel is initialized based on whether
* a server was already configured. * a server was already configured.
*/ */
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) { private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) { if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
val hasServerConfig = !serverUrl.isNullOrEmpty() && val hasServerConfig = !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" && serverUrl != "http://" &&
serverUrl != "https://" serverUrl != "https://"
// If server was configured → offlineMode = false (continue syncing) // If server was configured → offlineMode = false (continue syncing)
// If no server → offlineMode = true (new users / offline users) // If no server → offlineMode = true (new users / offline users)
val offlineModeValue = !hasServerConfig val offlineModeValue = !hasServerConfig
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply() prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue") Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
} }
} }

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

@@ -0,0 +1,36 @@
package dev.dettmer.simplenotes.di
import android.content.Context
import androidx.room.Room
import dev.dettmer.simplenotes.storage.AppDatabase
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.ui.main.MainViewModel
import dev.dettmer.simplenotes.utils.Constants
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val appModule = module {
single {
Room.databaseBuilder(
androidContext(),
AppDatabase::class.java,
"notes_database"
).build()
}
single { get<AppDatabase>().noteDao() }
single { get<AppDatabase>().deletedNoteDao() }
single { NotesStorage(androidContext(), get(), get()) }
// Provide SharedPreferences
single {
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
viewModel { MainViewModel(androidApplication()) }
}

View File

@@ -0,0 +1,19 @@
package dev.dettmer.simplenotes.storage
import android.content.Context
import androidx.room.Database
import androidx.room.Room
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.NoteDao
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
@TypeConverters(NoteConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
abstract fun deletedNoteDao(): DeletedNoteDao
}

View File

@@ -3,75 +3,99 @@ package dev.dettmer.simplenotes.storage
import android.content.Context import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
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.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 kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File import java.io.File
class NotesStorage(private val context: Context) { class NotesStorage(
private val context: Context,
private val noteDao: NoteDao,
private val deletedNoteDao: DeletedNoteDao,
) {
companion object { companion object {
private const val TAG = "NotesStorage" private const val TAG = "NotesStorage"
// 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen
private val deletionTrackerMutex = Mutex()
} }
private val notesDir: File = File(context.filesDir, "notes").apply {
if (!exists()) mkdirs() suspend fun saveNote(note: 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
)
)
} }
fun saveNote(note: Note) { suspend fun loadNote(id: String): Note? {
val file = File(notesDir, "${note.id}.json") return noteDao.getNote(id)?.let { note ->
file.writeText(note.toJson()) Note(
} id = note.id,
title = note.title,
fun loadNote(id: String): Note? { content = note.content,
val file = File(notesDir, "$id.json") createdAt = note.createdAt,
return if (file.exists()) { updatedAt = note.updatedAt,
Note.fromJson(file.readText()) deviceId = note.deviceId,
} else { syncStatus = note.syncStatus,
null noteType = note.noteType,
checklistItems = note.checklistItems,
checklistSortOption = note.checklistSortOption
)
} }
} }
/** suspend fun loadAllNotes(): List<Note> {
* Lädt alle Notizen aus dem lokalen Speicher. return noteDao.getAllNotes().map { note ->
* Note(
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt, id = note.id,
* damit der User die Sortierung konfigurieren kann. title = note.title,
*/ content = note.content,
fun loadAllNotes(): List<Note> { createdAt = note.createdAt,
return notesDir.listFiles() updatedAt = note.updatedAt,
?.filter { it.extension == "json" } deviceId = note.deviceId,
?.mapNotNull { Note.fromJson(it.readText()) } syncStatus = note.syncStatus,
?: emptyList() noteType = note.noteType,
checklistItems = note.checklistItems,
checklistSortOption = note.checklistSortOption
)
}
} }
fun deleteNote(id: String): Boolean { suspend fun deleteNote(id: String): Boolean {
val file = File(notesDir, "$id.json") val deleted = noteDao.deleteNoteById(id) > 0
val deleted = file.delete()
if (deleted) { if (deleted) {
Logger.d(TAG, "🗑️ Deleted note: $id")
// Track deletion to prevent zombie notes
val deviceId = DeviceIdGenerator.getDeviceId(context) val deviceId = DeviceIdGenerator.getDeviceId(context)
trackDeletion(id, deviceId) deletedNoteDao.trackDeletion(DeletedNoteEntity(id, deviceId))
} }
return deleted return deleted
} }
fun deleteAllNotes(): Boolean { suspend fun deleteAllNotes(): Boolean {
return try { return try {
val notes = loadAllNotes() val notes = noteDao.getAllNotes()
val deviceId = DeviceIdGenerator.getDeviceId(context)
noteDao.deleteAllNotes()
for (note in notes) { for (note in notes) {
deleteNote(note.id) // Uses trackDeletion() automatically 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) {
@@ -79,19 +103,19 @@ class NotesStorage(private val context: Context) {
false false
} }
} }
// === Deletion Tracking === // === Deletion Tracking ===
private fun getDeletionTrackerFile(): File { private fun getDeletionTrackerFile(): File {
return File(context.filesDir, "deleted_notes.json") return File(context.filesDir, "deleted_notes.json")
} }
fun loadDeletionTracker(): DeletionTracker { fun loadDeletionTracker(): DeletionTracker {
val file = getDeletionTrackerFile() val file = getDeletionTrackerFile()
if (!file.exists()) { if (!file.exists()) {
return DeletionTracker() return DeletionTracker()
} }
return try { return try {
val json = file.readText() val json = file.readText()
DeletionTracker.fromJson(json) ?: DeletionTracker() DeletionTracker.fromJson(json) ?: DeletionTracker()
@@ -100,83 +124,64 @@ class NotesStorage(private val context: Context) {
DeletionTracker() DeletionTracker()
} }
} }
fun saveDeletionTracker(tracker: DeletionTracker) { fun saveDeletionTracker(tracker: DeletionTracker) {
try { try {
val file = getDeletionTrackerFile() val file = getDeletionTrackerFile()
file.writeText(tracker.toJson()) file.writeText(tracker.toJson())
if (tracker.deletedNotes.size > 1000) { if (tracker.deletedNotes.size > 1000) {
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries") Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
} }
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)") Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to save deletion tracker", e) Logger.e(TAG, "Failed to save deletion tracker", e)
} }
} }
/** /**
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex * 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
* *
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff * Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
* auf den Deletion Tracker. * auf den Deletion Tracker.
* *
* @param noteId ID der gelöschten Notiz * @param noteId ID der gelöschten Notiz
* @param deviceId Geräte-ID für Konflikt-Erkennung * @param deviceId Geräte-ID für Konflikt-Erkennung
*/ */
suspend fun trackDeletionSafe(noteId: String, deviceId: String) { suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
deletionTrackerMutex.withLock { deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
val tracker = loadDeletionTracker()
tracker.addDeletion(noteId, deviceId)
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
}
} }
/** /**
* Legacy-Methode ohne Mutex-Schutz. * Legacy-Methode ohne Mutex-Schutz.
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind. * Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
* *
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich * @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
*/ */
fun trackDeletion(noteId: String, deviceId: String) { suspend fun trackDeletion(noteId: String, deviceId: String) {
val tracker = loadDeletionTracker() deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
tracker.addDeletion(noteId, deviceId)
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion: $noteId") Logger.d(TAG, "📝 Tracked deletion: $noteId")
} }
fun isNoteDeleted(noteId: String): Boolean { suspend fun isNoteDeleted(noteId: String): Boolean {
val tracker = loadDeletionTracker() return deletedNoteDao.isNoteDeleted(noteId)
return tracker.isDeleted(noteId)
} }
fun clearDeletionTracker() { suspend fun clearDeletionTracker() {
saveDeletionTracker(DeletionTracker()) 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 * 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
* This ensures notes are uploaded to the new server on next sync * This ensures notes are uploaded to the new server on next sync
*/ */
fun resetAllSyncStatusToPending(): Int { suspend fun resetAllSyncStatusToPending(): Int {
val notes = loadAllNotes() var updatedCount = noteDao.updateSyncStatus(SyncStatus.SYNCED, SyncStatus.PENDING)
var updatedCount = 0
notes.forEach { note ->
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
saveNote(updatedNote)
updatedCount++
}
}
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING") Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount return updatedCount
} }
fun getNotesDir(): File = notesDir
} }

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

@@ -0,0 +1,19 @@
package dev.dettmer.simplenotes.storage.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
@Dao
interface DeletedNoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun trackDeletion(deletedNote: DeletedNoteEntity)
@Query("SELECT EXISTS(SELECT 1 FROM deleted_notes WHERE noteId = :noteId)")
suspend fun isNoteDeleted(noteId: String): Boolean
@Query("DELETE FROM deleted_notes")
suspend fun clearTracker()
}

View File

@@ -0,0 +1,29 @@
package dev.dettmer.simplenotes.storage.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Dao
interface NoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveNote(note: NoteEntity)
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun getNote(id: String): NoteEntity?
@Query("SELECT * FROM notes")
suspend fun getAllNotes(): List<NoteEntity>
@Query("DELETE FROM notes WHERE id = :id")
suspend fun deleteNoteById(id: String): Int
@Query("DELETE FROM notes")
suspend fun deleteAllNotes(): Int
@Query("UPDATE notes SET syncStatus = :newStatus WHERE syncStatus = :oldStatus")
suspend fun updateSyncStatus(oldStatus: SyncStatus, newStatus: SyncStatus): Int
}

View File

@@ -0,0 +1,11 @@
package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "deleted_notes")
data class DeletedNoteEntity(
@PrimaryKey val noteId: String,
val deviceId: String,
val deletedAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,22 @@
package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey
val id: String,
val title: String,
val content: String,
val createdAt: Long,
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
@@ -44,11 +45,14 @@ import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger 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.java.KoinJavaComponent.inject
import kotlin.getValue
/** /**
* Main Activity with Jetpack Compose UI * Main Activity with Jetpack Compose UI
* v1.5.0: Complete MainActivity Redesign with Compose * v1.5.0: Complete MainActivity Redesign with Compose
* *
* Replaces the old 805-line MainActivity.kt with a modern * Replaces the old 805-line MainActivity.kt with a modern
* Compose-based implementation featuring: * Compose-based implementation featuring:
* - Notes list with swipe-to-delete * - Notes list with swipe-to-delete
@@ -58,22 +62,21 @@ import kotlinx.coroutines.launch
* - Design consistent with ComposeSettingsActivity * - Design consistent with ComposeSettingsActivity
*/ */
class ComposeMainActivity : ComponentActivity() { class ComposeMainActivity : ComponentActivity() {
companion object { companion object {
private const val TAG = "ComposeMainActivity" private const val TAG = "ComposeMainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001 private const val REQUEST_NOTIFICATION_PERMISSION = 1001
private const val REQUEST_SETTINGS = 1002 private const val REQUEST_SETTINGS = 1002
} }
private val viewModel: MainViewModel by viewModels() 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
/** /**
* BroadcastReceiver for Background-Sync Completion (Periodic Sync) * BroadcastReceiver for Background-Sync Completion (Periodic Sync)
*/ */
@@ -81,9 +84,9 @@ class ComposeMainActivity : ComponentActivity() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
val success = intent?.getBooleanExtra("success", false) ?: false val success = intent?.getBooleanExtra("success", false) ?: false
val count = intent?.getIntExtra("count", 0) ?: 0 val count = intent?.getIntExtra("count", 0) ?: 0
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count") Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
// UI refresh // UI refresh
if (success && count > 0) { if (success && count > 0) {
viewModel.loadNotes() viewModel.loadNotes()
@@ -91,60 +94,60 @@ class ComposeMainActivity : ComponentActivity() {
} }
} }
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Install Splash Screen (Android 12+) // Install Splash Screen (Android 12+)
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Material You (Android 12+) // Apply Dynamic Colors for Material You (Android 12+)
DynamicColors.applyToActivityIfAvailable(this) DynamicColors.applyToActivityIfAvailable(this)
// Enable edge-to-edge display // Enable edge-to-edge display
enableEdgeToEdge() enableEdgeToEdge()
// Initialize Logger and enable file logging if configured // Initialize Logger and enable file logging if configured
Logger.init(this) Logger.init(this)
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) { if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
Logger.setFileLoggingEnabled(true) Logger.setFileLoggingEnabled(true)
} }
// Clear old sync notifications on app start // Clear old sync notifications on app start
NotificationHelper.clearSyncNotifications(this) NotificationHelper.clearSyncNotifications(this)
// Request notification permission (Android 13+) // Request notification permission (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission() requestNotificationPermission()
} }
// v1.4.1: Migrate checklists for backwards compatibility // v1.4.1: Migrate checklists for backwards compatibility
migrateChecklistsForBackwardsCompat() migrateChecklistsForBackwardsCompat()
// Setup Sync State Observer // Setup Sync State Observer
setupSyncStateObserver() setupSyncStateObserver()
setContent { setContent {
SimpleNotesTheme { SimpleNotesTheme {
val context = LocalContext.current val context = LocalContext.current
// Dialog state for delete confirmation // Dialog state for delete confirmation
var deleteDialogData by remember { mutableStateOf<MainViewModel.DeleteDialogData?>(null) } var deleteDialogData by remember { mutableStateOf<MainViewModel.DeleteDialogData?>(null) }
// Handle delete dialog events // Handle delete dialog events
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.showDeleteDialog.collect { data -> viewModel.showDeleteDialog.collect { data ->
deleteDialogData = data deleteDialogData = data
} }
} }
// Handle toast events // Handle toast events
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.showToast.collect { message -> viewModel.showToast.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show() Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
} }
} }
// Delete confirmation dialog // Delete confirmation dialog
deleteDialogData?.let { data -> deleteDialogData?.let { data ->
DeleteConfirmationDialog( DeleteConfirmationDialog(
@@ -163,70 +166,70 @@ class ComposeMainActivity : ComponentActivity() {
} }
) )
} }
MainScreen( MainScreen(
viewModel = viewModel, viewModel = viewModel,
onOpenNote = { noteId -> openNoteEditor(noteId) }, onOpenNote = { noteId -> openNoteEditor(noteId) },
onOpenSettings = { openSettings() }, onOpenSettings = { openSettings() },
onCreateNote = { noteType -> createNote(noteType) } onCreateNote = { noteType -> createNote(noteType) }
) )
// v1.8.0: Post-Update Changelog (shows once after update) // v1.8.0: Post-Update Changelog (shows once after update)
UpdateChangelogSheet() UpdateChangelogSheet()
} }
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers") Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks) // 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
// This ensures UI reflects current offline mode when returning from Settings // This ensures UI reflects current offline mode when returning from Settings
viewModel.refreshOfflineModeState() viewModel.refreshOfflineModeState()
// 🎨 v1.7.0: Refresh display mode when returning from Settings // 🎨 v1.7.0: Refresh display mode when returning from Settings
viewModel.refreshDisplayMode() viewModel.refreshDisplayMode()
// Register BroadcastReceiver for Background-Sync // Register BroadcastReceiver for Background-Sync
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional @Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
LocalBroadcastManager.getInstance(this).registerReceiver( LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver, syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED) IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
) )
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)") Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
// Reload notes // Reload notes
viewModel.loadNotes() viewModel.loadNotes()
// Phase 3: Scroll to top if coming from editor (new/edited note) // Phase 3: Scroll to top if coming from editor (new/edited note)
if (cameFromEditor) { if (cameFromEditor) {
viewModel.scrollToTop() viewModel.scrollToTop()
cameFromEditor = false cameFromEditor = false
Logger.d(TAG, "📜 Came from editor - scrolling to top") Logger.d(TAG, "📜 Came from editor - scrolling to top")
} }
// Trigger Auto-Sync on app resume // Trigger Auto-Sync on app resume
viewModel.triggerAutoSync("onResume") viewModel.triggerAutoSync("onResume")
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
// Unregister BroadcastReceiver // Unregister BroadcastReceiver
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver) LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered") Logger.d(TAG, "📡 BroadcastReceiver unregistered")
} }
private fun setupSyncStateObserver() { private fun setupSyncStateObserver() {
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern) // 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
SyncStateManager.syncStatus.observe(this) { status -> SyncStateManager.syncStatus.observe(this) { status ->
viewModel.updateSyncState(status) viewModel.updateSyncState(status)
} }
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System) // 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
lifecycleScope.launch { lifecycleScope.launch {
SyncStateManager.syncProgress.collect { progress -> SyncStateManager.syncProgress.collect { progress ->
@@ -250,14 +253,14 @@ class ComposeMainActivity : ComponentActivity() {
} }
} }
} }
private fun openNoteEditor(noteId: String?) { private fun openNoteEditor(noteId: String?) {
cameFromEditor = true cameFromEditor = true
val intent = Intent(this, ComposeNoteEditorActivity::class.java) val intent = Intent(this, ComposeNoteEditorActivity::class.java)
noteId?.let { noteId?.let {
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it) intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
} }
// v1.5.0: Add slide animation // v1.5.0: Add slide animation
val options = ActivityOptions.makeCustomAnimation( val options = ActivityOptions.makeCustomAnimation(
this, this,
@@ -266,12 +269,12 @@ class ComposeMainActivity : ComponentActivity() {
) )
startActivity(intent, options.toBundle()) startActivity(intent, options.toBundle())
} }
private fun createNote(noteType: NoteType) { private fun createNote(noteType: NoteType) {
cameFromEditor = true cameFromEditor = true
val intent = Intent(this, ComposeNoteEditorActivity::class.java) val intent = Intent(this, ComposeNoteEditorActivity::class.java)
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name) intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
// v1.5.0: Add slide animation // v1.5.0: Add slide animation
val options = ActivityOptions.makeCustomAnimation( val options = ActivityOptions.makeCustomAnimation(
this, this,
@@ -280,7 +283,7 @@ class ComposeMainActivity : ComponentActivity() {
) )
startActivity(intent, options.toBundle()) startActivity(intent, options.toBundle())
} }
private fun openSettings() { private fun openSettings() {
val intent = Intent(this, ComposeSettingsActivity::class.java) val intent = Intent(this, ComposeSettingsActivity::class.java)
val options = ActivityOptions.makeCustomAnimation( val options = ActivityOptions.makeCustomAnimation(
@@ -291,10 +294,10 @@ class ComposeMainActivity : ComponentActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle()) startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
} }
private fun requestNotificationPermission() { private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) { != PackageManager.PERMISSION_GRANTED) {
requestPermissions( requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS), arrayOf(Manifest.permission.POST_NOTIFICATIONS),
@@ -303,54 +306,25 @@ 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")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) { if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
// Settings changed, reload notes // Settings changed, reload notes
viewModel.loadNotes() viewModel.loadNotes()
} }
} }
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts")) @Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
@@ -359,15 +333,15 @@ class ComposeMainActivity : ComponentActivity() {
grantResults: IntArray grantResults: IntArray
) { ) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) { when (requestCode) {
REQUEST_NOTIFICATION_PERMISSION -> { REQUEST_NOTIFICATION_PERMISSION -> {
if (grantResults.isNotEmpty() && if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) { grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show() Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
} else { } else {
Toast.makeText(this, Toast.makeText(this,
getString(R.string.toast_notifications_disabled), getString(R.string.toast_notifications_disabled),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} }
@@ -389,8 +363,8 @@ private fun DeleteConfirmationDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) }, title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
text = { text = {
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle)) Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {

View File

@@ -59,13 +59,14 @@ import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner
import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
/** /**
* Main screen displaying the notes list * Main screen displaying the notes list
* v1.5.0: Jetpack Compose MainActivity Redesign * v1.5.0: Jetpack Compose MainActivity Redesign
* *
* Performance optimized with proper state handling: * Performance optimized with proper state handling:
* - LazyListState for scroll control * - LazyListState for scroll control
* - Scaffold FAB slot for proper z-ordering * - Scaffold FAB slot for proper z-ordering
@@ -74,7 +75,7 @@ private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel, viewModel: MainViewModel = koinViewModel(),
onOpenNote: (String?) -> Unit, onOpenNote: (String?) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateNote: (NoteType) -> Unit onCreateNote: (NoteType) -> Unit
@@ -82,37 +83,37 @@ fun MainScreen(
val notes by viewModel.sortedNotes.collectAsState() val notes by viewModel.sortedNotes.collectAsState()
val syncState by viewModel.syncState.collectAsState() val syncState by viewModel.syncState.collectAsState()
val scrollToTop by viewModel.scrollToTop.collectAsState() val scrollToTop by viewModel.scrollToTop.collectAsState()
// 🆕 v1.8.0: Einziges Banner-System // 🆕 v1.8.0: Einziges Banner-System
val syncProgress by viewModel.syncProgress.collectAsState() val syncProgress by viewModel.syncProgress.collectAsState()
// Multi-Select State // Multi-Select State
val selectedNotes by viewModel.selectedNotes.collectAsState() val selectedNotes by viewModel.selectedNotes.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState()
// 🌟 v1.6.0: Reactive offline mode state // 🌟 v1.6.0: Reactive offline mode state
val isOfflineMode by viewModel.isOfflineMode.collectAsState() val isOfflineMode by viewModel.isOfflineMode.collectAsState()
// 🎨 v1.7.0: Display mode (list or grid) // 🎨 v1.7.0: Display mode (list or grid)
val displayMode by viewModel.displayMode.collectAsState() val displayMode by viewModel.displayMode.collectAsState()
// Delete confirmation dialog state // Delete confirmation dialog state
var showBatchDeleteDialog by remember { mutableStateOf(false) } var showBatchDeleteDialog by remember { mutableStateOf(false) }
// 🆕 v1.8.0: Sync status legend dialog // 🆕 v1.8.0: Sync status legend dialog
var showSyncLegend by remember { mutableStateOf(false) } var showSyncLegend by remember { mutableStateOf(false) }
// 🔀 v1.8.0: Sort dialog state // 🔀 v1.8.0: Sort dialog state
var showSortDialog by remember { mutableStateOf(false) } var showSortDialog by remember { mutableStateOf(false) }
val sortOption by viewModel.sortOption.collectAsState() val sortOption by viewModel.sortOption.collectAsState()
val sortDirection by viewModel.sortDirection.collectAsState() val sortDirection by viewModel.sortDirection.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
// 🎨 v1.7.0: gridState für Staggered Grid Layout // 🎨 v1.7.0: gridState für Staggered Grid Layout
val gridState = rememberLazyStaggeredGridState() val gridState = rememberLazyStaggeredGridState()
// ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times // ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times
var timestampTicker by remember { mutableStateOf(0L) } var timestampTicker by remember { mutableStateOf(0L) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -121,17 +122,17 @@ fun MainScreen(
timestampTicker = System.currentTimeMillis() timestampTicker = System.currentTimeMillis()
} }
} }
// Compute isSyncing once // Compute isSyncing once
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
// 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes) // 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes)
// Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState() // Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
// which is called in ComposeMainActivity.onResume() when returning from Settings // which is called in ComposeMainActivity.onResume() when returning from Settings
val hasServerConfig = viewModel.hasServerConfig() val hasServerConfig = viewModel.hasServerConfig()
val isSyncAvailable = !isOfflineMode && hasServerConfig val isSyncAvailable = !isOfflineMode && hasServerConfig
val canSync = isSyncAvailable && !isSyncing val canSync = isSyncAvailable && !isSyncing
// Handle snackbar events from ViewModel // Handle snackbar events from ViewModel
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.showSnackbar.collect { data -> viewModel.showSnackbar.collect { data ->
@@ -147,7 +148,7 @@ fun MainScreen(
} }
} }
} }
// Phase 3: Scroll to top when new note created // Phase 3: Scroll to top when new note created
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid) // 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
LaunchedEffect(scrollToTop) { LaunchedEffect(scrollToTop) {
@@ -160,7 +161,7 @@ fun MainScreen(
viewModel.resetScrollToTop() viewModel.resetScrollToTop()
} }
} }
// v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit // v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit
Scaffold( Scaffold(
topBar = { topBar = {
@@ -213,7 +214,7 @@ fun MainScreen(
progress = syncProgress, progress = syncProgress,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// Content: Empty state or notes list // Content: Empty state or notes list
if (notes.isEmpty()) { if (notes.isEmpty()) {
EmptyState(modifier = Modifier.weight(1f)) EmptyState(modifier = Modifier.weight(1f))
@@ -249,7 +250,7 @@ fun MainScreen(
listState = listState, listState = listState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onNoteClick = { note -> onOpenNote(note.id) }, onNoteClick = { note -> onOpenNote(note.id) },
onNoteLongPress = { note -> onNoteLongPress = { note ->
// Long-press starts selection mode // Long-press starts selection mode
viewModel.startSelectionMode(note.id) viewModel.startSelectionMode(note.id)
}, },
@@ -260,7 +261,7 @@ fun MainScreen(
} }
} }
} }
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode // FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
AnimatedVisibility( AnimatedVisibility(
visible = !isSelectionMode, visible = !isSelectionMode,
@@ -277,7 +278,7 @@ fun MainScreen(
} }
} }
} }
// Batch Delete Confirmation Dialog // Batch Delete Confirmation Dialog
if (showBatchDeleteDialog) { if (showBatchDeleteDialog) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
@@ -294,14 +295,14 @@ fun MainScreen(
} }
) )
} }
// 🆕 v1.8.0: Sync Status Legend Dialog // 🆕 v1.8.0: Sync Status Legend Dialog
if (showSyncLegend) { if (showSyncLegend) {
SyncStatusLegendDialog( SyncStatusLegendDialog(
onDismiss = { showSyncLegend = false } onDismiss = { showSyncLegend = false }
) )
} }
// 🔀 v1.8.0: Sort Dialog // 🔀 v1.8.0: Sort Dialog
if (showSortDialog) { if (showSortDialog) {
SortDialog( SortDialog(
@@ -344,7 +345,7 @@ private fun MainTopBar(
contentDescription = stringResource(R.string.sort_notes) contentDescription = stringResource(R.string.sort_notes)
) )
} }
// 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar) // 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar)
if (showSyncLegend) { if (showSyncLegend) {
IconButton(onClick = onSyncLegendClick) { IconButton(onClick = onSyncLegendClick) {

View File

@@ -2,12 +2,15 @@ package dev.dettmer.simplenotes.ui.main
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.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
@@ -27,54 +30,56 @@ 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
* v1.5.0: Jetpack Compose MainActivity Redesign * v1.5.0: Jetpack Compose MainActivity Redesign
* *
* Manages notes list, sync state, and deletion with undo. * Manages notes list, sync state, and deletion with undo.
*/ */
class MainViewModel(application: Application) : AndroidViewModel(application) { class MainViewModel(application: Application) : AndroidViewModel(application) {
companion object { companion object {
private const val TAG = "MainViewModel" private const val TAG = "MainViewModel"
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute 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 PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
} }
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)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Notes State // Notes State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _notes = MutableStateFlow<List<Note>>(emptyList()) private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow() val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet()) private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow() val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Multi-Select State (v1.5.0) // Multi-Select State (v1.5.0)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet()) private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow() val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
val isSelectionMode: StateFlow<Boolean> = _selectedNotes val isSelectionMode: StateFlow<Boolean> = _selectedNotes
.map { it.isNotEmpty() } .map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Offline Mode State (reactive) // 🌟 v1.6.0: Offline Mode State (reactive)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
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()
/** /**
* Refresh offline mode state from SharedPreferences * Refresh offline mode state from SharedPreferences
* Called when returning from Settings screen (in onResume) * Called when returning from Settings screen (in onResume)
@@ -85,16 +90,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_isOfflineMode.value = newValue _isOfflineMode.value = newValue
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue$newValue") Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue$newValue")
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Mode State // 🎨 v1.7.0: Display Mode 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()
/** /**
* Refresh display mode from SharedPreferences * Refresh display mode from SharedPreferences
* Called when returning from Settings screen * Called when returning from Settings screen
@@ -104,25 +109,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_displayMode.value = newValue _displayMode.value = newValue
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value}$newValue") Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value}$newValue")
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 🔀 v1.8.0: Sort State // 🔀 v1.8.0: Sort State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _sortOption = MutableStateFlow( private val _sortOption = MutableStateFlow(
SortOption.fromPrefsValue( SortOption.fromPrefsValue(
prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION
) )
) )
val sortOption: StateFlow<SortOption> = _sortOption.asStateFlow() val sortOption: StateFlow<SortOption> = _sortOption.asStateFlow()
private val _sortDirection = MutableStateFlow( private val _sortDirection = MutableStateFlow(
SortDirection.fromPrefsValue( SortDirection.fromPrefsValue(
prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION
) )
) )
val sortDirection: StateFlow<SortDirection> = _sortDirection.asStateFlow() val sortDirection: StateFlow<SortDirection> = _sortDirection.asStateFlow()
/** /**
* 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection. * 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection.
* Reagiert automatisch auf Änderungen in allen drei Flows. * Reagiert automatisch auf Änderungen in allen drei Flows.
@@ -138,68 +143,68 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList() initialValue = emptyList()
) )
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Sync State // Sync State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress // 🆕 v1.8.0: Einziges Banner-System - SyncProgress
val syncProgress: StateFlow<SyncProgress> = SyncStateManager.syncProgress val syncProgress: StateFlow<SyncProgress> = SyncStateManager.syncProgress
// Intern: SyncState für PullToRefresh-Indikator // Intern: SyncState für PullToRefresh-Indikator
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE) private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow() val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// UI Events // UI Events
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private val _showToast = MutableSharedFlow<String>() private val _showToast = MutableSharedFlow<String>()
val showToast: SharedFlow<String> = _showToast.asSharedFlow() val showToast: SharedFlow<String> = _showToast.asSharedFlow()
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>() private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow() val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
private val _showSnackbar = MutableSharedFlow<SnackbarData>() private val _showSnackbar = MutableSharedFlow<SnackbarData>()
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow() val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
// Phase 3: Scroll-to-top when new note is created // Phase 3: Scroll-to-top when new note is created
private val _scrollToTop = MutableStateFlow(false) private val _scrollToTop = MutableStateFlow(false)
val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow() val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow()
// Track first note ID to detect new notes // Track first note ID to detect new notes
private var previousFirstNoteId: String? = null private var previousFirstNoteId: String? = null
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Data Classes // Data Classes
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
data class DeleteDialogData( data class DeleteDialogData(
val note: Note, val note: Note,
val originalList: List<Note> val originalList: List<Note>
) )
data class SnackbarData( data class SnackbarData(
val message: String, val message: String,
val actionLabel: String, val actionLabel: String,
val onAction: () -> Unit val onAction: () -> Unit
) )
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Initialization // Initialization
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
init { init {
// v1.5.0 Performance: Load notes asynchronously to avoid blocking UI // v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
loadNotesAsync() loadNotesAsync()
} }
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Notes Actions // Notes Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/** /**
* Load notes asynchronously on IO dispatcher * Load notes asynchronously on IO dispatcher
* This prevents UI blocking during app startup * This prevents UI blocking during app startup
@@ -208,23 +213,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val pendingIds = _pendingDeletions.value val pendingIds = _pendingDeletions.value
val filteredNotes = allNotes.filter { it.id !in pendingIds } val filteredNotes = allNotes.filter { it.id !in pendingIds }
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
val newFirstNoteId = filteredNotes.firstOrNull()?.id val newFirstNoteId = filteredNotes.firstOrNull()?.id
if (newFirstNoteId != null && if (newFirstNoteId != null &&
previousFirstNoteId != null && previousFirstNoteId != null &&
newFirstNoteId != previousFirstNoteId) { newFirstNoteId != previousFirstNoteId) {
// New note at top → trigger scroll // New note at top → trigger scroll
_scrollToTop.value = true _scrollToTop.value = true
Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top") Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top")
} }
previousFirstNoteId = newFirstNoteId previousFirstNoteId = newFirstNoteId
_notes.value = filteredNotes _notes.value = filteredNotes
} }
} }
/** /**
* Public loadNotes - delegates to async version * Public loadNotes - delegates to async version
*/ */
@@ -233,25 +238,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadNotesAsync() loadNotesAsync()
} }
} }
/** /**
* Reset scroll-to-top flag after scroll completed * Reset scroll-to-top flag after scroll completed
*/ */
fun resetScrollToTop() { fun resetScrollToTop() {
_scrollToTop.value = false _scrollToTop.value = false
} }
/** /**
* Force scroll to top (e.g., after returning from editor) * Force scroll to top (e.g., after returning from editor)
*/ */
fun scrollToTop() { fun scrollToTop() {
_scrollToTop.value = true _scrollToTop.value = true
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Multi-Select Actions (v1.5.0) // Multi-Select Actions (v1.5.0)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/** /**
* Toggle selection of a note * Toggle selection of a note
*/ */
@@ -262,56 +267,56 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_selectedNotes.value + noteId _selectedNotes.value + noteId
} }
} }
/** /**
* Start selection mode with initial note * Start selection mode with initial note
*/ */
fun startSelectionMode(noteId: String) { fun startSelectionMode(noteId: String) {
_selectedNotes.value = setOf(noteId) _selectedNotes.value = setOf(noteId)
} }
/** /**
* Select all notes * Select all notes
*/ */
fun selectAllNotes() { fun selectAllNotes() {
_selectedNotes.value = _notes.value.map { it.id }.toSet() _selectedNotes.value = _notes.value.map { it.id }.toSet()
} }
/** /**
* Clear selection and exit selection mode * Clear selection and exit selection mode
*/ */
fun clearSelection() { fun clearSelection() {
_selectedNotes.value = emptySet() _selectedNotes.value = emptySet()
} }
/** /**
* Get count of selected notes * Get count of selected notes
*/ */
fun getSelectedCount(): Int = _selectedNotes.value.size fun getSelectedCount(): Int = _selectedNotes.value.size
/** /**
* 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()
// Delete from storage // Delete from storage
selectedNotes.forEach { note -> selectedNotes.forEach { note ->
storage.deleteNote(note.id) storage.deleteNote(note.id)
} }
// Clear selection // Clear selection
clearSelection() clearSelection()
// Reload notes // Reload notes
loadNotes() loadNotes()
// Show snackbar with undo // Show snackbar with undo
val count = selectedNotes.size val count = selectedNotes.size
val message = if (deleteFromServer) { val message = if (deleteFromServer) {
@@ -319,7 +324,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
getString(R.string.snackbar_notes_deleted_local, count) getString(R.string.snackbar_notes_deleted_local, count)
} }
viewModelScope.launch { viewModelScope.launch {
_showSnackbar.emit(SnackbarData( _showSnackbar.emit(SnackbarData(
message = message, message = message,
@@ -328,7 +333,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
undoDeleteMultiple(selectedNotes) undoDeleteMultiple(selectedNotes)
} }
)) ))
@Suppress("MagicNumber") // Snackbar timing coordination @Suppress("MagicNumber") // Snackbar timing coordination
// If delete from server, actually delete after a short delay // If delete from server, actually delete after a short delay
// (to allow undo action before server deletion) // (to allow undo action before server deletion)
@@ -347,19 +352,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
} }
/** /**
* 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()
// Restore to storage // Restore to storage
notes.forEach { note -> notes.forEach { note ->
storage.saveNote(note) storage.saveNote(note)
} }
// Reload notes // Reload notes
loadNotes() loadNotes()
} }
@@ -370,10 +375,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
*/ */
fun onNoteLongPressDelete(note: Note) { fun onNoteLongPressDelete(note: Note) {
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false) val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
// Store original list for potential restore // Store original list for potential restore
val originalList = _notes.value.toList() val originalList = _notes.value.toList()
if (alwaysDeleteFromServer) { if (alwaysDeleteFromServer) {
// Auto-delete without dialog // Auto-delete without dialog
deleteNoteConfirmed(note, deleteFromServer = true) deleteNoteConfirmed(note, deleteFromServer = true)
@@ -392,34 +397,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun onNoteSwipedToDelete(note: Note) { fun onNoteSwipedToDelete(note: Note) {
onNoteLongPressDelete(note) // Delegate to long-press handler onNoteLongPressDelete(note) // Delegate to long-press handler
} }
/** /**
* Restore note after swipe (user cancelled dialog) * Restore note after swipe (user cancelled dialog)
*/ */
fun restoreNoteAfterSwipe(originalList: List<Note>) { fun restoreNoteAfterSwipe(originalList: List<Note>) {
_notes.value = originalList _notes.value = originalList
} }
/** /**
* 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
// Delete from storage // Delete from storage
storage.deleteNote(note.id) storage.deleteNote(note.id)
// Reload notes // Reload notes
loadNotes() loadNotes()
// Show snackbar with undo // Show snackbar with undo
val message = if (deleteFromServer) { val message = if (deleteFromServer) {
getString(R.string.snackbar_note_deleted_server, note.title) getString(R.string.snackbar_note_deleted_server, note.title)
} else { } else {
getString(R.string.snackbar_note_deleted_local, note.title) getString(R.string.snackbar_note_deleted_local, note.title)
} }
viewModelScope.launch { viewModelScope.launch {
_showSnackbar.emit(SnackbarData( _showSnackbar.emit(SnackbarData(
message = message, message = message,
@@ -428,7 +433,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
undoDelete(note) undoDelete(note)
} }
)) ))
@Suppress("MagicNumber") // Snackbar timing @Suppress("MagicNumber") // Snackbar timing
// If delete from server, actually delete after snackbar timeout // If delete from server, actually delete after snackbar timeout
if (deleteFromServer) { if (deleteFromServer) {
@@ -443,21 +448,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
} }
/** /**
* 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
// Restore to storage // Restore to storage
storage.saveNote(note) storage.saveNote(note)
// Reload notes // Reload notes
loadNotes() loadNotes()
} }
/** /**
* Actually delete note from server after snackbar dismissed * Actually delete note from server after snackbar dismissed
*/ */
@@ -468,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId) webdavService.deleteNoteFromServer(noteId)
} }
if (success) { if (success) {
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO // 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server)) SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
@@ -483,7 +488,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
} }
/** /**
* Delete multiple notes from server with aggregated toast * Delete multiple notes from server with aggregated toast
* Shows single toast at the end instead of one per note * Shows single toast at the end instead of one per note
@@ -493,7 +498,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val webdavService = WebDavSyncService(getApplication()) val webdavService = WebDavSyncService(getApplication())
var successCount = 0 var successCount = 0
var failCount = 0 var failCount = 0
noteIds.forEach { noteId -> noteIds.forEach { noteId ->
try { try {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
@@ -507,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_pendingDeletions.value = _pendingDeletions.value - noteId _pendingDeletions.value = _pendingDeletions.value - noteId
} }
} }
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR // 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
val message = when { val message = when {
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount) failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
@@ -525,22 +530,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
} }
/** /**
* Finalize deletion (remove from pending set) * Finalize deletion (remove from pending set)
*/ */
fun finalizeDeletion(noteId: String) { fun finalizeDeletion(noteId: String) {
_pendingDeletions.value = _pendingDeletions.value - noteId _pendingDeletions.value = _pendingDeletions.value - noteId
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Sync Actions // Sync Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
fun updateSyncState(status: SyncStateManager.SyncStatus) { fun updateSyncState(status: SyncStateManager.SyncStatus) {
_syncState.value = status.state _syncState.value = status.state
} }
/** /**
* Trigger manual sync (from toolbar button or pull-to-refresh) * Trigger manual sync (from toolbar button or pull-to-refresh)
* v1.7.0: Uses central canSync() gate for WiFi-only check * v1.7.0: Uses central canSync() gate for WiFi-only check
@@ -559,14 +564,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
return return
} }
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach) // 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach)
// Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren) // Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren)
val prefs = getApplication<android.app.Application>().getSharedPreferences( val prefs = getApplication<android.app.Application>().getSharedPreferences(
Constants.PREFS_NAME, Constants.PREFS_NAME,
android.content.Context.MODE_PRIVATE android.content.Context.MODE_PRIVATE
) )
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft // 🆕 v1.7.0: Feedback wenn Sync bereits läuft
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant // 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
if (!SyncStateManager.tryStartSync(source)) { if (!SyncStateManager.tryStartSync(source)) {
@@ -582,10 +587,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
return return
} }
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch) // 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch)
SyncStateManager.markGlobalSyncStarted(prefs) SyncStateManager.markGlobalSyncStarted(prefs)
viewModelScope.launch { viewModelScope.launch {
try { try {
// Check for unsynced changes (Banner zeigt bereits PREPARING) // Check for unsynced changes (Banner zeigt bereits PREPARING)
@@ -595,23 +600,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadNotes() loadNotes()
return@launch return@launch
} }
// Check server reachability // Check server reachability
val isReachable = withContext(Dispatchers.IO) { val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable() syncService.isServerReachable()
} }
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ $source Sync: Server not reachable") Logger.d(TAG, "⏭️ $source Sync: Server not reachable")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable)) SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch return@launch
} }
// Perform sync // Perform sync
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.syncNotes() syncService.syncNotes()
} }
if (result.isSuccess) { if (result.isSuccess) {
// 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen // 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen
val bannerMessage = buildString { val bannerMessage = buildString {
@@ -636,7 +641,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
} }
/** /**
* Trigger auto-sync (onResume) * Trigger auto-sync (onResume)
* Only runs if server is configured and interval has passed * Only runs if server is configured and interval has passed
@@ -650,17 +655,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
Logger.d(TAG, "⏭️ onResume sync disabled - skipping") Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
return return
} }
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen) // 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen)
if (!SyncStateManager.canSyncGlobally(prefs)) { if (!SyncStateManager.canSyncGlobally(prefs)) {
return return
} }
// Throttling check (eigener 60s-Cooldown für onResume) // Throttling check (eigener 60s-Cooldown für onResume)
if (!canTriggerAutoSync()) { if (!canTriggerAutoSync()) {
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()
@@ -672,22 +677,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
return return
} }
// v1.5.0: silent=true → kein Banner bei Auto-Sync // v1.5.0: silent=true → kein Banner bei Auto-Sync
// 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar // 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) { if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress") Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return return
} }
Logger.d(TAG, "🔄 Auto-sync triggered ($source)") Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp // Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply() prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
// 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren // 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren
SyncStateManager.markGlobalSyncStarted(prefs) SyncStateManager.markGlobalSyncStarted(prefs)
viewModelScope.launch { viewModelScope.launch {
try { try {
// Check for unsynced changes // Check for unsynced changes
@@ -696,23 +701,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
SyncStateManager.reset() // Silent → geht direkt auf IDLE SyncStateManager.reset() // Silent → geht direkt auf IDLE
return@launch return@launch
} }
// Check server reachability // Check server reachability
val isReachable = withContext(Dispatchers.IO) { val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable() syncService.isServerReachable()
} }
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset() // Silent → kein Error-Banner SyncStateManager.reset() // Silent → kein Error-Banner
return@launch return@launch
} }
// Perform sync // Perform sync
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.syncNotes() syncService.syncNotes()
} }
if (result.isSuccess && result.syncedCount > 0) { if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
// 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync // 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync
@@ -734,25 +739,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
} }
private fun canTriggerAutoSync(): Boolean { private fun canTriggerAutoSync(): Boolean {
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0) val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastSyncTime val timeSinceLastSync = now - lastSyncTime
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) { if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000 val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s") Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
return false return false
} }
return true return true
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 🔀 v1.8.0: Sortierung // 🔀 v1.8.0: Sortierung
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/** /**
* 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung. * 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung.
*/ */
@@ -768,13 +773,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
SortOption.NOTE_TYPE -> compareBy<Note> { it.noteType.ordinal } SortOption.NOTE_TYPE -> compareBy<Note> { it.noteType.ordinal }
.thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen .thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen
} }
return when (direction) { return when (direction) {
SortDirection.ASCENDING -> notes.sortedWith(comparator) SortDirection.ASCENDING -> notes.sortedWith(comparator)
SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed()) SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed())
} }
} }
/** /**
* 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences. * 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences.
*/ */
@@ -783,7 +788,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply() prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply()
Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}") Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}")
} }
/** /**
* 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences. * 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences.
*/ */
@@ -792,7 +797,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply() prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply()
Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}") Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}")
} }
/** /**
* 🔀 v1.8.0: Toggelt die Sortierrichtung. * 🔀 v1.8.0: Toggelt die Sortierrichtung.
*/ */
@@ -800,16 +805,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val newDirection = _sortDirection.value.toggle() val newDirection = _sortDirection.value.toggle()
setSortDirection(newDirection) setSortDirection(newDirection)
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Helpers // Helpers
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId) private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
private fun getString(resId: Int, vararg formatArgs: Any): String = private fun getString(resId: Int, vararg formatArgs: Any): String =
getApplication<android.app.Application>().getString(resId, *formatArgs) getApplication<android.app.Application>().getString(resId, *formatArgs)
fun isServerConfigured(): Boolean { fun isServerConfigured(): Boolean {
// 🌟 v1.6.0: Use reactive offline mode state // 🌟 v1.6.0: Use reactive offline mode state
if (_isOfflineMode.value) { if (_isOfflineMode.value) {
@@ -818,7 +823,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
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://"
} }
/** /**
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode) * 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
* Used for determining if sync would be available when offline mode is disabled * Used for determining if sync would be available when offline mode is disabled
@@ -827,4 +832,37 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
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

@@ -1,17 +0,0 @@
package dev.dettmer.simplenotes
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -16,6 +16,9 @@ composeBom = "2026.01.00"
navigationCompose = "2.7.6" navigationCompose = "2.7.6"
lifecycleRuntimeCompose = "2.7.0" lifecycleRuntimeCompose = "2.7.0"
activityCompose = "1.8.2" activityCompose = "1.8.2"
room = "2.6.1"
ksp = "2.0.21-1.0.27"
koin = "3.5.3"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -37,6 +40,14 @@ androidx-compose-material-icons = { group = "androidx.compose.material", name =
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
# Room Database
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Core Koin for Kotlin projects
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
# Koin for Jetpack Compose integration
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -44,4 +55,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }