mid commit

This commit is contained in:
2026-02-18 00:45:02 +00:00
parent 5764e8c0ec
commit 7ddad7e5e7
15 changed files with 588 additions and 540 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

@@ -36,8 +36,6 @@ import android.widget.CheckBox
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
@@ -56,30 +54,30 @@ import dev.dettmer.simplenotes.models.NoteType
*/ */
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0 @Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var recyclerViewNotes: RecyclerView private lateinit var recyclerViewNotes: RecyclerView
private lateinit var emptyStateCard: MaterialCardView private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout private lateinit var swipeRefreshLayout: SwipeRefreshLayout
// 🔄 v1.3.1: Sync Status Banner // 🔄 v1.3.1: Sync Status Banner
private lateinit var syncStatusBanner: LinearLayout private lateinit var syncStatusBanner: LinearLayout
private lateinit var syncStatusText: TextView private lateinit var syncStatusText: TextView
private lateinit var adapter: NotesAdapter private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) } private val storage by lazy { NotesStorage(this) }
// Menu reference for sync button state // Menu reference for sync button state
private var optionsMenu: Menu? = null private var optionsMenu: Menu? = null
// Track pending deletions to prevent flicker when notes reload // Track pending deletions to prevent flicker when notes reload
private val pendingDeletions = mutableSetOf<String>() private val pendingDeletions = mutableSetOf<String>()
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
} }
companion object { companion object {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001 private const val REQUEST_NOTIFICATION_PERMISSION = 1001
@@ -89,7 +87,7 @@ class MainActivity : AppCompatActivity() {
private const val SYNC_COMPLETED_DELAY_MS = 1500L private const val SYNC_COMPLETED_DELAY_MS = 1500L
private const val ERROR_DISPLAY_DELAY_MS = 3000L private const val ERROR_DISPLAY_DELAY_MS = 3000L
} }
/** /**
* BroadcastReceiver für Background-Sync Completion (Periodic Sync) * BroadcastReceiver für Background-Sync Completion (Periodic Sync)
*/ */
@@ -97,9 +95,9 @@ class MainActivity : AppCompatActivity() {
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) {
loadNotes() loadNotes()
@@ -107,49 +105,49 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
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 Android 12+ (Material You) // Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this) DynamicColors.applyToActivityIfAvailable(this)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
// Logger initialisieren und File-Logging aktivieren wenn eingestellt // Logger initialisieren und File-Logging aktivieren wenn eingestellt
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)
} }
// Alte Sync-Notifications beim App-Start löschen // Alte Sync-Notifications beim App-Start löschen
NotificationHelper.clearSyncNotifications(this) NotificationHelper.clearSyncNotifications(this)
// Permission für Notifications (Android 13+) // Permission für Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission() requestNotificationPermission()
} }
// 🌍 v1.7.2: Debug Locale für Fehlersuche // 🌍 v1.7.2: Debug Locale für Fehlersuche
logLocaleInfo() logLocaleInfo()
findViews() findViews()
setupToolbar() setupToolbar()
setupRecyclerView() setupRecyclerView()
setupFab() setupFab()
// v1.4.1: Migrate checklists for backwards compatibility // v1.4.1: Migrate checklists for backwards compatibility
migrateChecklistsForBackwardsCompat() migrateChecklistsForBackwardsCompat()
loadNotes() loadNotes()
// 🔄 v1.3.1: Observe sync state for UI updates // 🔄 v1.3.1: Observe sync state for UI updates
setupSyncStateObserver() setupSyncStateObserver()
} }
/** /**
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback * 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
*/ */
@@ -200,7 +198,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
/** /**
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh) * 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
*/ */
@@ -210,32 +208,32 @@ class MainActivity : AppCompatActivity() {
// SwipeRefresh // SwipeRefresh
swipeRefreshLayout.isEnabled = enabled swipeRefreshLayout.isEnabled = enabled
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers") Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers")
// Register BroadcastReceiver für Background-Sync // Register BroadcastReceiver für Background-Sync
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 (scroll to top wird in loadNotes() gemacht) // Reload notes (scroll to top wird in loadNotes() gemacht)
loadNotes() loadNotes()
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast) // Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
triggerAutoSync("onResume") triggerAutoSync("onResume")
} }
/** /**
* Automatischer Sync (onResume) * Automatischer Sync (onResume)
* - Nutzt WiFi-gebundenen Socket (VPN Fix!) * - Nutzt WiFi-gebundenen Socket (VPN Fix!)
* - Nur Success-Toast (kein "Auto-Sync..." Toast) * - Nur Success-Toast (kein "Auto-Sync..." Toast)
* *
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!) * 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 * v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
*/ */
@@ -244,65 +242,65 @@ class MainActivity : AppCompatActivity() {
if (!canTriggerAutoSync()) { if (!canTriggerAutoSync()) {
return return
} }
// 🔄 v1.3.1: Check if sync already running // 🔄 v1.3.1: Check if sync already running
// v1.5.0: silent=true - kein Banner bei Auto-Sync // v1.5.0: silent=true - kein Banner bei Auto-Sync
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()
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val syncService = WebDavSyncService(this@MainActivity) val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping") Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset() SyncStateManager.reset()
return@launch return@launch
} }
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker) // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
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() SyncStateManager.reset()
return@launch return@launch
} }
// Server ist erreichbar → Sync durchführen // Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.syncNotes() syncService.syncNotes()
} }
// Feedback abhängig von Source // Feedback abhängig von Source
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")
SyncStateManager.markCompleted("${result.syncedCount} Notizen") SyncStateManager.markCompleted("${result.syncedCount} Notizen")
// onResume: Nur Success-Toast // onResume: Nur Success-Toast
showToast("✅ Gesynct: ${result.syncedCount} Notizen") showToast("✅ Gesynct: ${result.syncedCount} Notizen")
loadNotes() loadNotes()
} else if (result.isSuccess) { } else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes") Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted() SyncStateManager.markCompleted()
} else { } else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}") Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage) SyncStateManager.markError(result.errorMessage)
// Kein Toast - App ist im Hintergrund // Kein Toast - App ist im Hintergrund
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}") Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
SyncStateManager.markError(e.message) SyncStateManager.markError(e.message)
@@ -310,7 +308,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
/** /**
* Prüft ob Auto-Sync getriggert werden darf (Throttling) * Prüft ob Auto-Sync getriggert werden darf (Throttling)
*/ */
@@ -318,96 +316,96 @@ class MainActivity : AppCompatActivity() {
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
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
// Unregister BroadcastReceiver // Unregister BroadcastReceiver
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver) LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered") Logger.d(TAG, "📡 BroadcastReceiver unregistered")
} }
private fun findViews() { private fun findViews() {
recyclerViewNotes = findViewById(R.id.recyclerViewNotes) recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
emptyStateCard = findViewById(R.id.emptyStateCard) emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote) fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar) toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout) swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
// 🔄 v1.3.1: Sync Status Banner // 🔄 v1.3.1: Sync Status Banner
syncStatusBanner = findViewById(R.id.syncStatusBanner) syncStatusBanner = findViewById(R.id.syncStatusBanner)
syncStatusText = findViewById(R.id.syncStatusText) syncStatusText = findViewById(R.id.syncStatusText)
} }
private fun setupToolbar() { private fun setupToolbar() {
setSupportActionBar(toolbar) setSupportActionBar(toolbar)
} }
private fun setupRecyclerView() { private fun setupRecyclerView() {
adapter = NotesAdapter { note -> adapter = NotesAdapter { note ->
openNoteEditor(note.id) openNoteEditor(note.id)
} }
recyclerViewNotes.adapter = adapter recyclerViewNotes.adapter = adapter
recyclerViewNotes.layoutManager = LinearLayoutManager(this) recyclerViewNotes.layoutManager = LinearLayoutManager(this)
// 🔥 v1.1.2: Setup Pull-to-Refresh // 🔥 v1.1.2: Setup Pull-to-Refresh
setupPullToRefresh() setupPullToRefresh()
// Setup Swipe-to-Delete // Setup Swipe-to-Delete
setupSwipeToDelete() setupSwipeToDelete()
} }
/** /**
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2) * Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
*/ */
private fun setupPullToRefresh() { private fun setupPullToRefresh() {
swipeRefreshLayout.setOnRefreshListener { swipeRefreshLayout.setOnRefreshListener {
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync") Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status) // 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("pullToRefresh")) { if (!SyncStateManager.tryStartSync("pullToRefresh")) {
swipeRefreshLayout.isRefreshing = false swipeRefreshLayout.isRefreshing = false
return@setOnRefreshListener return@setOnRefreshListener
} }
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) { if (serverUrl.isNullOrEmpty()) {
showToast("⚠️ Server noch nicht konfiguriert") showToast("⚠️ Server noch nicht konfiguriert")
SyncStateManager.reset() SyncStateManager.reset()
return@launch return@launch
} }
val syncService = WebDavSyncService(this@MainActivity) val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check") Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced)) SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
return@launch return@launch
} }
// Check if server is reachable // Check if server is reachable
if (!syncService.isServerReachable()) { if (!syncService.isServerReachable()) {
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 = syncService.syncNotes() val result = syncService.syncNotes()
if (result.isSuccess) { if (result.isSuccess) {
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount)) SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes() loadNotes()
@@ -420,13 +418,13 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
// Set Material 3 color scheme // Set Material 3 color scheme
swipeRefreshLayout.setColorSchemeResources( swipeRefreshLayout.setColorSchemeResources(
com.google.android.material.R.color.material_dynamic_primary50 com.google.android.material.R.color.material_dynamic_primary50
) )
} }
private fun setupSwipeToDelete() { private fun setupSwipeToDelete() {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, // No drag 0, // No drag
@@ -437,45 +435,45 @@ class MainActivity : AppCompatActivity() {
viewHolder: RecyclerView.ViewHolder, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder target: RecyclerView.ViewHolder
): Boolean = false ): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.bindingAdapterPosition val position = viewHolder.bindingAdapterPosition
val swipedNote = adapter.currentList[position] val swipedNote = adapter.currentList[position]
// Store original list BEFORE removing note // Store original list BEFORE removing note
val originalList = adapter.currentList.toList() val originalList = adapter.currentList.toList()
// Remove from list for visual feedback (NOT from storage yet!) // Remove from list for visual feedback (NOT from storage yet!)
val listWithoutNote = originalList.toMutableList().apply { val listWithoutNote = originalList.toMutableList().apply {
removeAt(position) removeAt(position)
} }
adapter.submitList(listWithoutNote) adapter.submitList(listWithoutNote)
// Show dialog with ability to restore // Show dialog with ability to restore
showServerDeletionDialog(swipedNote, originalList) showServerDeletionDialog(swipedNote, originalList)
} }
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
// Require 80% swipe to trigger // Require 80% swipe to trigger
return 0.8f return 0.8f
} }
}) })
itemTouchHelper.attachToRecyclerView(recyclerViewNotes) itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
} }
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) { private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false) val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
if (alwaysDeleteFromServer) { if (alwaysDeleteFromServer) {
// Auto-delete from server without asking // Auto-delete from server without asking
deleteNoteLocally(note, deleteFromServer = true) deleteNoteLocally(note, deleteFromServer = true)
return return
} }
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null) val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer) val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.legacy_delete_dialog_title)) .setTitle(getString(R.string.legacy_delete_dialog_title))
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title)) .setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
@@ -504,24 +502,24 @@ class MainActivity : AppCompatActivity() {
.setCancelable(true) .setCancelable(true)
.show() .show()
} }
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) { private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
// Track pending deletion to prevent flicker // Track pending deletion to prevent flicker
pendingDeletions.add(note.id) pendingDeletions.add(note.id)
// Delete from storage // Delete from storage
storage.deleteNote(note.id) storage.deleteNote(note.id)
// Reload to reflect changes // Reload to reflect changes
loadNotes() loadNotes()
// Show Snackbar with UNDO option // Show Snackbar with UNDO option
val message = if (deleteFromServer) { val message = if (deleteFromServer) {
getString(R.string.legacy_delete_with_server, note.title) getString(R.string.legacy_delete_with_server, note.title)
} else { } else {
getString(R.string.legacy_delete_local_only, note.title) getString(R.string.legacy_delete_local_only, note.title)
} }
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG) Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction(getString(R.string.snackbar_undo)) { .setAction(getString(R.string.snackbar_undo)) {
// UNDO: Restore note // UNDO: Restore note
@@ -534,7 +532,7 @@ class MainActivity : AppCompatActivity() {
if (event != DISMISS_EVENT_ACTION) { if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO // Snackbar dismissed without UNDO
pendingDeletions.remove(note.id) pendingDeletions.remove(note.id)
// Delete from server if requested // Delete from server if requested
if (deleteFromServer) { if (deleteFromServer) {
lifecycleScope.launch { lifecycleScope.launch {
@@ -573,7 +571,7 @@ class MainActivity : AppCompatActivity() {
} }
}).show() }).show()
} }
/** /**
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl * v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
*/ */
@@ -582,14 +580,14 @@ class MainActivity : AppCompatActivity() {
showNoteTypePopup(view) showNoteTypePopup(view)
} }
} }
/** /**
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs * v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
*/ */
private fun showNoteTypePopup(anchor: View) { private fun showNoteTypePopup(anchor: View) {
val popupMenu = PopupMenu(this, anchor, Gravity.END) val popupMenu = PopupMenu(this, anchor, Gravity.END)
popupMenu.inflate(R.menu.menu_fab_note_types) popupMenu.inflate(R.menu.menu_fab_note_types)
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet) // Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
try { try {
val fields = popupMenu.javaClass.declaredFields val fields = popupMenu.javaClass.declaredFields
@@ -606,29 +604,29 @@ class MainActivity : AppCompatActivity() {
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}") Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
} }
popupMenu.setOnMenuItemClickListener { menuItem -> popupMenu.setOnMenuItemClickListener { menuItem ->
val noteType = when (menuItem.itemId) { val noteType = when (menuItem.itemId) {
R.id.action_create_text_note -> NoteType.TEXT R.id.action_create_text_note -> NoteType.TEXT
R.id.action_create_checklist -> NoteType.CHECKLIST R.id.action_create_checklist -> NoteType.CHECKLIST
else -> return@setOnMenuItemClickListener false else -> return@setOnMenuItemClickListener false
} }
val intent = Intent(this, NoteEditorActivity::class.java) val intent = Intent(this, NoteEditorActivity::class.java)
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name) intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent) startActivity(intent)
true true
} }
popupMenu.show() popupMenu.show()
} }
private fun loadNotes() { private fun loadNotes() {
val notes = storage.loadAllNotes() val notes = storage.loadAllNotes()
// Filter out notes that are pending deletion (prevent flicker) // Filter out notes that are pending deletion (prevent flicker)
val filteredNotes = notes.filter { it.id !in pendingDeletions } val filteredNotes = notes.filter { it.id !in pendingDeletions }
// Submit list with callback to scroll to top after list is updated // Submit list with callback to scroll to top after list is updated
adapter.submitList(filteredNotes) { adapter.submitList(filteredNotes) {
// Scroll to top after list update is complete // Scroll to top after list update is complete
@@ -637,7 +635,7 @@ class MainActivity : AppCompatActivity() {
recyclerViewNotes.scrollToPosition(0) recyclerViewNotes.scrollToPosition(0)
} }
} }
// Material 3 Empty State Card // Material 3 Empty State Card
emptyStateCard.visibility = if (filteredNotes.isEmpty()) { emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
android.view.View.VISIBLE android.view.View.VISIBLE
@@ -645,7 +643,7 @@ class MainActivity : AppCompatActivity() {
android.view.View.GONE android.view.View.GONE
} }
} }
private fun openNoteEditor(noteId: String?) { private fun openNoteEditor(noteId: String?) {
val intent = Intent(this, NoteEditorActivity::class.java) val intent = Intent(this, NoteEditorActivity::class.java)
noteId?.let { noteId?.let {
@@ -653,25 +651,25 @@ class MainActivity : AppCompatActivity() {
} }
startActivity(intent) startActivity(intent)
} }
private fun openSettings() { private fun openSettings() {
// v1.5.0: Use new Jetpack Compose Settings // v1.5.0: Use new Jetpack Compose Settings
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java) val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS) startActivityForResult(intent, REQUEST_SETTINGS)
} }
private fun triggerManualSync() { private fun triggerManualSync() {
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status) // 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("manual")) { if (!SyncStateManager.tryStartSync("manual")) {
return return
} }
lifecycleScope.launch { lifecycleScope.launch {
try { try {
// Create sync service // Create sync service
val syncService = WebDavSyncService(this@MainActivity) val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping") Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
@@ -679,23 +677,23 @@ class MainActivity : AppCompatActivity() {
SyncStateManager.markCompleted(message) SyncStateManager.markCompleted(message)
return@launch return@launch
} }
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker) // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) { val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable() syncService.isServerReachable()
} }
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting") Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable)) SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch return@launch
} }
// Server ist erreichbar → Sync durchführen // Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.syncNotes() syncService.syncNotes()
} }
// Show result // Show result
if (result.isSuccess) { if (result.isSuccess) {
SyncStateManager.markCompleted("${result.syncedCount} Notizen") SyncStateManager.markCompleted("${result.syncedCount} Notizen")
@@ -703,19 +701,19 @@ class MainActivity : AppCompatActivity() {
} else { } else {
SyncStateManager.markError(result.errorMessage) SyncStateManager.markError(result.errorMessage)
} }
} catch (e: Exception) { } catch (e: Exception) {
SyncStateManager.markError(e.message) SyncStateManager.markError(e.message)
} }
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu) menuInflater.inflate(R.menu.menu_main, menu)
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
return true return true
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_settings -> { R.id.action_settings -> {
@@ -729,10 +727,10 @@ class MainActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
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),
@@ -741,50 +739,50 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
@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) {
// Restore was successful, reload notes // Restore was successful, reload notes
loadNotes() loadNotes()
} }
} }
/** /**
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität. * v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
* *
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren * Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird. * App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
* *
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren, * Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden. * damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
* *
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr * 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). * im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX * Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
*/ */
private fun migrateChecklistsForBackwardsCompat() { private fun migrateChecklistsForBackwardsCompat() {
val migrationKey = "v1.4.1_checklist_migration_done" val migrationKey = "v1.4.1_checklist_migration_done"
// Nur einmal ausführen // Nur einmal ausführen
if (prefs.getBoolean(migrationKey, false)) { if (prefs.getBoolean(migrationKey, false)) {
return return
} }
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note -> val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST && note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() && note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true note.checklistItems?.isNotEmpty() == true
} }
if (checklistsToMigrate.isNotEmpty()) { if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content") Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) { for (note in checklistsToMigrate) {
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content // Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
// generiert und hochgeladen wird // generiert und hochgeladen wird
val updatedNote = note.copy( val updatedNote = note.copy(
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
@@ -792,24 +790,24 @@ class MainActivity : AppCompatActivity() {
storage.saveNote(updatedNote) storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}") Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
} }
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync") Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
} }
// Migration als erledigt markieren // Migration als erledigt markieren
prefs.edit().putBoolean(migrationKey, true).apply() prefs.edit().putBoolean(migrationKey, true).apply()
} }
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<out String>, permissions: Array<out String>,
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) {
showToast(getString(R.string.toast_notifications_enabled)) showToast(getString(R.string.toast_notifications_enabled))
} else { } else {
@@ -818,39 +816,39 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
/** /**
* 🌍 v1.7.2: Debug-Logging für Locale-Problem * 🌍 v1.7.2: Debug-Logging für Locale-Problem
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden * Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
*/ */
private fun logLocaleInfo() { private fun logLocaleInfo() {
if (!BuildConfig.DEBUG) return if (!BuildConfig.DEBUG) return
Logger.d(TAG, "╔═══════════════════════════════════════════════════") Logger.d(TAG, "╔═══════════════════════════════════════════════════")
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO") Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
Logger.d(TAG, "╠═══════════════════════════════════════════════════") Logger.d(TAG, "╠═══════════════════════════════════════════════════")
// System Locale // System Locale
val systemLocale = java.util.Locale.getDefault() val systemLocale = java.util.Locale.getDefault()
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale") Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
// Resources Locale // Resources Locale
val resourcesLocale = resources.configuration.locales[0] val resourcesLocale = resources.configuration.locales[0]
Logger.d(TAG, "║ Resources Locale: $resourcesLocale") Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
// Context Locale (API 24+) // Context Locale (API 24+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val contextLocales = resources.configuration.locales val contextLocales = resources.configuration.locales
Logger.d(TAG, "║ Context Locales (all): $contextLocales") Logger.d(TAG, "║ Context Locales (all): $contextLocales")
} }
// Test String Loading // Test String Loading
val testString = getString(R.string.toast_already_synced) val testString = getString(R.string.toast_already_synced)
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)") Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
Logger.d(TAG, "║ Result: '$testString'") Logger.d(TAG, "║ Result: '$testString'")
Logger.d(TAG, "║ Expected EN: '✅ Already synced'") Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}") Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
Logger.d(TAG, "╚═══════════════════════════════════════════════════") Logger.d(TAG, "╚═══════════════════════════════════════════════════")
} }
} }

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

@@ -0,0 +1,33 @@
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.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() }
// Provide SharedPreferences
single {
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
single { NotesStorage(androidContext(), get()) }
viewModel { MainViewModel(get(), get()) }
}

View File

@@ -0,0 +1,14 @@
package dev.dettmer.simplenotes.storage
import androidx.room.Database
import androidx.room.RoomDatabase
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)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
abstract fun deletedNoteDao(): DeletedNoteDao
}

View File

@@ -1,77 +1,59 @@
package dev.dettmer.simplenotes.storage package dev.dettmer.simplenotes.storage
import android.content.Context import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.models.Note 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
class NotesStorage(private val context: Context) { class NotesStorage(
private val context: Context,
database: AppDatabase
) {
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 { private val noteDao = database.noteDao()
if (!exists()) mkdirs() private val deletedNoteDao = database.deletedNoteDao()
suspend fun saveNote(note: NoteEntity) {
noteDao.saveNote(note)
} }
fun saveNote(note: Note) { suspend fun loadNote(id: String): NoteEntity? {
val file = File(notesDir, "${note.id}.json") return noteDao.getNote(id)
file.writeText(note.toJson())
} }
fun loadNote(id: String): Note? { suspend fun loadAllNotes(): List<NoteEntity> {
val file = File(notesDir, "$id.json") return noteDao.getAllNotes()
return if (file.exists()) {
Note.fromJson(file.readText())
} else {
null
}
} }
/** suspend fun deleteNote(id: String): Boolean {
* Lädt alle Notizen aus dem lokalen Speicher. val deletedRows = noteDao.deleteNoteById(id)
*
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt, if (deletedRows > 0) {
* damit der User die Sortierung konfigurieren kann.
*/
fun loadAllNotes(): List<Note> {
return notesDir.listFiles()
?.filter { it.extension == "json" }
?.mapNotNull { Note.fromJson(it.readText()) }
?: emptyList()
}
fun deleteNote(id: String): Boolean {
val file = File(notesDir, "$id.json")
val deleted = file.delete()
if (deleted) {
Logger.d(TAG, "🗑️ Deleted note: $id") 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) trackDeletionSafe(id, deviceId)
return true
} }
return false
return deleted
} }
fun deleteAllNotes(): Boolean { suspend fun deleteAllNotes(): Boolean {
return try { return try {
val notes = loadAllNotes() val notes = loadAllNotes()
val deviceId = DeviceIdGenerator.getDeviceId(context) val deviceId = DeviceIdGenerator.getDeviceId(context)
for (note in notes) { // Batch tracking and deleting
deleteNote(note.id) // Uses trackDeletion() automatically notes.forEach { note ->
trackDeletionSafe(note.id, deviceId)
} }
noteDao.deleteAllNotes()
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,104 +61,31 @@ class NotesStorage(private val context: Context) {
false false
} }
} }
// === Deletion Tracking === // === Deletion Tracking ===
private fun getDeletionTrackerFile(): File {
return File(context.filesDir, "deleted_notes.json")
}
fun loadDeletionTracker(): DeletionTracker {
val file = getDeletionTrackerFile()
if (!file.exists()) {
return DeletionTracker()
}
return try {
val json = file.readText()
DeletionTracker.fromJson(json) ?: DeletionTracker()
} catch (e: Exception) {
Logger.e(TAG, "Failed to load deletion tracker", e)
DeletionTracker()
}
}
fun saveDeletionTracker(tracker: DeletionTracker) {
try {
val file = getDeletionTrackerFile()
file.writeText(tracker.toJson())
if (tracker.deletedNotes.size > 1000) {
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
}
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
} catch (e: Exception) {
Logger.e(TAG, "Failed to save deletion tracker", e)
}
}
/**
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
*
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
* auf den Deletion Tracker.
*
* @param noteId ID der gelöschten Notiz
* @param deviceId Geräte-ID für Konflikt-Erkennung
*/
suspend fun trackDeletionSafe(noteId: String, deviceId: String) { suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
deletionTrackerMutex.withLock { // Room handles internal transactions and thread-safety natively.
val tracker = loadDeletionTracker() // The Mutex is no longer required.
tracker.addDeletion(noteId, deviceId) deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
}
}
/**
* Legacy-Methode ohne Mutex-Schutz.
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
*
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
*/
fun trackDeletion(noteId: String, deviceId: String) {
val tracker = loadDeletionTracker()
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")
} }
/** suspend fun resetAllSyncStatusToPending(): Int {
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes val updatedCount = noteDao.updateSyncStatus(
* This ensures notes are uploaded to the new server on next sync oldStatus = SyncStatus.SYNCED,
*/ newStatus = SyncStatus.PENDING
fun resetAllSyncStatusToPending(): Int { )
val notes = loadAllNotes()
var updatedCount = 0
notes.forEach { note ->
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
saveNote(updatedNote)
updatedCount++
}
}
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING") Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount return updatedCount
} }
fun getNotesDir(): File = notesDir
} }

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,13 @@
package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import dev.dettmer.simplenotes.models.SyncStatus
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val content: String,
val timestamp: Long,
val syncStatus: SyncStatus
)

View File

@@ -44,11 +44,12 @@ 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
/** /**
* 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 +59,22 @@ 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 prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
} }
// 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 +82,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 +92,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 +164,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 +251,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 +267,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 +281,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 +292,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,29 +304,29 @@ 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" val migrationKey = "v1.4.1_checklist_migration_done"
// Only run once // Only run once
if (prefs.getBoolean(migrationKey, false)) { if (prefs.getBoolean(migrationKey, false)) {
return return
} }
val storage = NotesStorage(this) val storage = NotesStorage(this)
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note -> val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST && note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() && note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true note.checklistItems?.isNotEmpty() == true
} }
if (checklistsToMigrate.isNotEmpty()) { if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content") Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) { for (note in checklistsToMigrate) {
val updatedNote = note.copy( val updatedNote = note.copy(
syncStatus = SyncStatus.PENDING syncStatus = SyncStatus.PENDING
@@ -333,24 +334,24 @@ class ComposeMainActivity : ComponentActivity() {
storage.saveNote(updatedNote) storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}") Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
} }
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync") Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
} }
// Mark migration as done // Mark migration as done
prefs.edit().putBoolean(migrationKey, true).apply() 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 +360,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 +390,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,7 +2,9 @@ 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SortDirection import dev.dettmer.simplenotes.models.SortDirection
@@ -31,50 +33,50 @@ import kotlinx.coroutines.withContext
/** /**
* 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(
private val storage: NotesStorage,
private val prefs: SharedPreferences
) : ViewModel() {
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 prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 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 +87,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 +106,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 +140,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
@@ -207,24 +209,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private suspend fun loadNotesAsync() { private suspend fun loadNotesAsync() {
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val pendingIds = _pendingDeletions.value val pendingIds = _pendingDeletions.value
val filteredNotes = allNotes.filter { it.id !in pendingIds } val filteredNotes = allNotes.filter { it.id !in pendingIds }.map { Note(
id = it.id,
content = it.content
) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// Phase 3: Detect if a new note was added at the top // Phase 3: Detect if a new note was added at the top
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) {
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
// 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>) {
// 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) {
// 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) {
// 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

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.0-1.0.21"
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" }