diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 455fcab..88be83e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { 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.detekt) + alias(libs.plugins.ksp) } import java.util.Properties @@ -25,13 +26,13 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - + // Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility dependenciesInfo { includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK includeInBundle = false // Also disable for AAB (Google Play) } - + // Product Flavors for F-Droid and standard builds // Note: APK splits are disabled to ensure single APK output flavorDimensions += "distribution" @@ -42,7 +43,7 @@ android { // All dependencies in this project are already FOSS-compatible // No APK splits - F-Droid expects single universal APK } - + create("standard") { dimension = "distribution" // Standard builds can include Play Services in the future if needed @@ -57,7 +58,7 @@ android { if (keystorePropertiesFile.exists()) { val keystoreProperties = Properties() keystoreProperties.load(FileInputStream(keystorePropertiesFile)) - + storeFile = file(keystoreProperties.getProperty("storeFile")) storePassword = keystoreProperties.getProperty("storePassword") keyAlias = keystoreProperties.getProperty("keyAlias") @@ -72,11 +73,11 @@ android { applicationIdSuffix = ".debug" versionNameSuffix = "-debug" isDebuggable = true - + // Optionales separates Icon-Label für Debug-Builds resValue("string", "app_name_debug", "Simple Notes (Debug)") } - + release { isMinifyEnabled = true isShrinkResources = true @@ -98,12 +99,12 @@ android { buildConfig = true // Enable BuildConfig generation compose = true // v1.5.0: Jetpack Compose für Settings Redesign } - + // v1.7.0: Mock Android framework classes in unit tests (Log, etc.) testOptions { unitTests.isReturnDefaultValues = true } - + // v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance // v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard // composeCompiler { } @@ -162,6 +163,15 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) 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 // ═══════════════════════════════════════════════════════════════════════ @@ -180,7 +190,7 @@ ktlint { outputToConsole = true ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts enableExperimentalRules = false - + filter { exclude("**/generated/**") exclude("**/build/**") @@ -196,7 +206,7 @@ detekt { allRules = false config.setFrom(files("$rootDir/config/detekt/detekt.yml")) baseline = file("$rootDir/config/detekt/baseline.xml") - + // Parallel-Verarbeitung für schnellere Checks parallel = true } @@ -205,13 +215,13 @@ detekt { // Single source of truth: F-Droid changelogs are reused in the app tasks.register("copyChangelogsToAssets") { description = "Copies F-Droid changelogs to app assets for post-update dialog" - + from("$rootDir/../fastlane/metadata/android") { include("*/changelogs/*.txt") } - + into("$projectDir/src/main/assets/changelogs") - + // Preserve directory structure: en-US/20.txt, de-DE/20.txt eachFile { val parts = relativePath.segments @@ -222,11 +232,11 @@ tasks.register("copyChangelogsToAssets") { relativePath = RelativePath(true, parts[0], parts[2]) } } - + includeEmptyDirs = false } // Run before preBuild to ensure changelogs are available tasks.named("preBuild") { dependsOn("copyChangelogsToAssets") -} \ No newline at end of file +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt index 0b2bfbb..a60c4bf 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt @@ -36,8 +36,6 @@ import android.widget.CheckBox import android.widget.Toast import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import dev.dettmer.simplenotes.sync.WebDavSyncService @@ -56,30 +54,30 @@ import dev.dettmer.simplenotes.models.NoteType */ @Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0 class MainActivity : AppCompatActivity() { - + private lateinit var recyclerViewNotes: RecyclerView private lateinit var emptyStateCard: MaterialCardView private lateinit var fabAddNote: FloatingActionButton private lateinit var toolbar: MaterialToolbar private lateinit var swipeRefreshLayout: SwipeRefreshLayout - + // 🔄 v1.3.1: Sync Status Banner private lateinit var syncStatusBanner: LinearLayout private lateinit var syncStatusText: TextView - + private lateinit var adapter: NotesAdapter private val storage by lazy { NotesStorage(this) } - + // Menu reference for sync button state private var optionsMenu: Menu? = null - + // Track pending deletions to prevent flicker when notes reload private val pendingDeletions = mutableSetOf() - + private val prefs by lazy { getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) } - + companion object { private const val TAG = "MainActivity" private const val REQUEST_NOTIFICATION_PERMISSION = 1001 @@ -89,7 +87,7 @@ class MainActivity : AppCompatActivity() { private const val SYNC_COMPLETED_DELAY_MS = 1500L private const val ERROR_DISPLAY_DELAY_MS = 3000L } - + /** * BroadcastReceiver für Background-Sync Completion (Periodic Sync) */ @@ -97,9 +95,9 @@ class MainActivity : AppCompatActivity() { override fun onReceive(context: Context?, intent: Intent?) { val success = intent?.getBooleanExtra("success", false) ?: false val count = intent?.getIntExtra("count", 0) ?: 0 - + Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count") - + // UI refresh if (success && count > 0) { loadNotes() @@ -107,49 +105,49 @@ class MainActivity : AppCompatActivity() { } } } - + override fun onCreate(savedInstanceState: Bundle?) { // Install Splash Screen (Android 12+) installSplashScreen() - + super.onCreate(savedInstanceState) - + // Apply Dynamic Colors for Android 12+ (Material You) DynamicColors.applyToActivityIfAvailable(this) - + setContentView(R.layout.activity_main) - + // Logger initialisieren und File-Logging aktivieren wenn eingestellt Logger.init(this) if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) { Logger.setFileLoggingEnabled(true) } - + // Alte Sync-Notifications beim App-Start löschen NotificationHelper.clearSyncNotifications(this) - + // Permission für Notifications (Android 13+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { requestNotificationPermission() } - + // 🌍 v1.7.2: Debug Locale für Fehlersuche logLocaleInfo() - + findViews() setupToolbar() setupRecyclerView() setupFab() - + // v1.4.1: Migrate checklists for backwards compatibility migrateChecklistsForBackwardsCompat() - + loadNotes() - + // 🔄 v1.3.1: Observe sync state for UI updates setupSyncStateObserver() } - + /** * 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback */ @@ -200,7 +198,7 @@ class MainActivity : AppCompatActivity() { } } } - + /** * 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh) */ @@ -210,32 +208,32 @@ class MainActivity : AppCompatActivity() { // SwipeRefresh swipeRefreshLayout.isEnabled = enabled } - + override fun onResume() { super.onResume() - + Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers") - + // Register BroadcastReceiver für Background-Sync LocalBroadcastManager.getInstance(this).registerReceiver( syncCompletedReceiver, IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED) ) - + Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)") - + // Reload notes (scroll to top wird in loadNotes() gemacht) loadNotes() - + // Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast) triggerAutoSync("onResume") } - + /** * Automatischer Sync (onResume) * - Nutzt WiFi-gebundenen Socket (VPN Fix!) * - Nur Success-Toast (kein "Auto-Sync..." Toast) - * + * * NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!) * v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt */ @@ -244,65 +242,65 @@ class MainActivity : AppCompatActivity() { if (!canTriggerAutoSync()) { return } - + // 🔄 v1.3.1: Check if sync already running // v1.5.0: silent=true - kein Banner bei Auto-Sync if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) { Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress") return } - + Logger.d(TAG, "🔄 Auto-sync triggered ($source)") - + // Update last sync timestamp prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply() - + lifecycleScope.launch { try { val syncService = WebDavSyncService(this@MainActivity) - + // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping") SyncStateManager.reset() return@launch } - + // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker) val isReachable = withContext(Dispatchers.IO) { syncService.isServerReachable() } - + if (!isReachable) { Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") SyncStateManager.reset() return@launch } - + // Server ist erreichbar → Sync durchführen val result = withContext(Dispatchers.IO) { syncService.syncNotes() } - + // Feedback abhängig von Source if (result.isSuccess && result.syncedCount > 0) { Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") SyncStateManager.markCompleted("${result.syncedCount} Notizen") - + // onResume: Nur Success-Toast showToast("✅ Gesynct: ${result.syncedCount} Notizen") loadNotes() - + } else if (result.isSuccess) { Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes") SyncStateManager.markCompleted() - + } else { Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}") SyncStateManager.markError(result.errorMessage) // Kein Toast - App ist im Hintergrund } - + } catch (e: Exception) { Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}") SyncStateManager.markError(e.message) @@ -310,7 +308,7 @@ class MainActivity : AppCompatActivity() { } } } - + /** * 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 now = System.currentTimeMillis() val timeSinceLastSync = now - lastSyncTime - + if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) { val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000 Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s") return false } - + return true } - + override fun onPause() { super.onPause() - + // Unregister BroadcastReceiver LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver) Logger.d(TAG, "📡 BroadcastReceiver unregistered") } - + private fun findViews() { recyclerViewNotes = findViewById(R.id.recyclerViewNotes) emptyStateCard = findViewById(R.id.emptyStateCard) fabAddNote = findViewById(R.id.fabAddNote) toolbar = findViewById(R.id.toolbar) swipeRefreshLayout = findViewById(R.id.swipeRefreshLayout) - + // 🔄 v1.3.1: Sync Status Banner syncStatusBanner = findViewById(R.id.syncStatusBanner) syncStatusText = findViewById(R.id.syncStatusText) } - + private fun setupToolbar() { setSupportActionBar(toolbar) } - + private fun setupRecyclerView() { adapter = NotesAdapter { note -> openNoteEditor(note.id) } recyclerViewNotes.adapter = adapter recyclerViewNotes.layoutManager = LinearLayoutManager(this) - + // 🔥 v1.1.2: Setup Pull-to-Refresh setupPullToRefresh() - + // Setup Swipe-to-Delete setupSwipeToDelete() } - + /** * Setup Pull-to-Refresh für manuellen Sync (v1.1.2) */ private fun setupPullToRefresh() { swipeRefreshLayout.setOnRefreshListener { Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync") - + // 🔄 v1.3.1: Check if sync already running (Banner zeigt Status) if (!SyncStateManager.tryStartSync("pullToRefresh")) { swipeRefreshLayout.isRefreshing = false return@setOnRefreshListener } - + lifecycleScope.launch { try { val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) - + if (serverUrl.isNullOrEmpty()) { showToast("⚠️ Server noch nicht konfiguriert") SyncStateManager.reset() return@launch } - + val syncService = WebDavSyncService(this@MainActivity) - + // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check") SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced)) return@launch } - + // Check if server is reachable if (!syncService.isServerReachable()) { SyncStateManager.markError(getString(R.string.snackbar_server_unreachable)) return@launch } - + // Perform sync val result = syncService.syncNotes() - + if (result.isSuccess) { SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount)) loadNotes() @@ -420,13 +418,13 @@ class MainActivity : AppCompatActivity() { } } } - + // Set Material 3 color scheme swipeRefreshLayout.setColorSchemeResources( com.google.android.material.R.color.material_dynamic_primary50 ) } - + private fun setupSwipeToDelete() { val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( 0, // No drag @@ -437,45 +435,45 @@ class MainActivity : AppCompatActivity() { viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean = false - + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val position = viewHolder.bindingAdapterPosition val swipedNote = adapter.currentList[position] - + // Store original list BEFORE removing note val originalList = adapter.currentList.toList() - + // Remove from list for visual feedback (NOT from storage yet!) val listWithoutNote = originalList.toMutableList().apply { removeAt(position) } adapter.submitList(listWithoutNote) - + // Show dialog with ability to restore showServerDeletionDialog(swipedNote, originalList) } - + override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { // Require 80% swipe to trigger return 0.8f } }) - + itemTouchHelper.attachToRecyclerView(recyclerViewNotes) } - + private fun showServerDeletionDialog(note: Note, originalList: List) { val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false) - + if (alwaysDeleteFromServer) { // Auto-delete from server without asking deleteNoteLocally(note, deleteFromServer = true) return } - + val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null) val checkboxAlways = dialogView.findViewById(R.id.checkboxAlwaysDeleteFromServer) - + MaterialAlertDialogBuilder(this) .setTitle(getString(R.string.legacy_delete_dialog_title)) .setMessage(getString(R.string.legacy_delete_dialog_message, note.title)) @@ -504,24 +502,24 @@ class MainActivity : AppCompatActivity() { .setCancelable(true) .show() } - + private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) { // Track pending deletion to prevent flicker pendingDeletions.add(note.id) - + // Delete from storage storage.deleteNote(note.id) - + // Reload to reflect changes loadNotes() - + // Show Snackbar with UNDO option val message = if (deleteFromServer) { getString(R.string.legacy_delete_with_server, note.title) } else { getString(R.string.legacy_delete_local_only, note.title) } - + Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG) .setAction(getString(R.string.snackbar_undo)) { // UNDO: Restore note @@ -534,7 +532,7 @@ class MainActivity : AppCompatActivity() { if (event != DISMISS_EVENT_ACTION) { // Snackbar dismissed without UNDO pendingDeletions.remove(note.id) - + // Delete from server if requested if (deleteFromServer) { lifecycleScope.launch { @@ -573,7 +571,7 @@ class MainActivity : AppCompatActivity() { } }).show() } - + /** * v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl */ @@ -582,14 +580,14 @@ class MainActivity : AppCompatActivity() { showNoteTypePopup(view) } } - + /** * v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs */ private fun showNoteTypePopup(anchor: View) { val popupMenu = PopupMenu(this, anchor, Gravity.END) popupMenu.inflate(R.menu.menu_fab_note_types) - + // Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet) try { val fields = popupMenu.javaClass.declaredFields @@ -606,29 +604,29 @@ class MainActivity : AppCompatActivity() { } catch (e: Exception) { Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}") } - + popupMenu.setOnMenuItemClickListener { menuItem -> val noteType = when (menuItem.itemId) { R.id.action_create_text_note -> NoteType.TEXT R.id.action_create_checklist -> NoteType.CHECKLIST else -> return@setOnMenuItemClickListener false } - + val intent = Intent(this, NoteEditorActivity::class.java) intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name) startActivity(intent) true } - + popupMenu.show() } - + private fun loadNotes() { val notes = storage.loadAllNotes() - + // Filter out notes that are pending deletion (prevent flicker) val filteredNotes = notes.filter { it.id !in pendingDeletions } - + // Submit list with callback to scroll to top after list is updated adapter.submitList(filteredNotes) { // Scroll to top after list update is complete @@ -637,7 +635,7 @@ class MainActivity : AppCompatActivity() { recyclerViewNotes.scrollToPosition(0) } } - + // Material 3 Empty State Card emptyStateCard.visibility = if (filteredNotes.isEmpty()) { android.view.View.VISIBLE @@ -645,7 +643,7 @@ class MainActivity : AppCompatActivity() { android.view.View.GONE } } - + private fun openNoteEditor(noteId: String?) { val intent = Intent(this, NoteEditorActivity::class.java) noteId?.let { @@ -653,25 +651,25 @@ class MainActivity : AppCompatActivity() { } startActivity(intent) } - + private fun openSettings() { // v1.5.0: Use new Jetpack Compose Settings val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java) @Suppress("DEPRECATION") startActivityForResult(intent, REQUEST_SETTINGS) } - + private fun triggerManualSync() { // 🔄 v1.3.1: Check if sync already running (Banner zeigt Status) if (!SyncStateManager.tryStartSync("manual")) { return } - + lifecycleScope.launch { try { // Create sync service val syncService = WebDavSyncService(this@MainActivity) - + // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping") @@ -679,23 +677,23 @@ class MainActivity : AppCompatActivity() { SyncStateManager.markCompleted(message) return@launch } - + // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker) val isReachable = withContext(Dispatchers.IO) { syncService.isServerReachable() } - + if (!isReachable) { Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting") SyncStateManager.markError(getString(R.string.snackbar_server_unreachable)) return@launch } - + // Server ist erreichbar → Sync durchführen val result = withContext(Dispatchers.IO) { syncService.syncNotes() } - + // Show result if (result.isSuccess) { SyncStateManager.markCompleted("${result.syncedCount} Notizen") @@ -703,19 +701,19 @@ class MainActivity : AppCompatActivity() { } else { SyncStateManager.markError(result.errorMessage) } - + } catch (e: Exception) { SyncStateManager.markError(e.message) } } } - + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_main, menu) optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state return true } - + override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_settings -> { @@ -729,10 +727,10 @@ class MainActivity : AppCompatActivity() { else -> super.onOptionsItemSelected(item) } } - + private fun requestNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { requestPermissions( arrayOf(Manifest.permission.POST_NOTIFICATIONS), @@ -741,50 +739,50 @@ class MainActivity : AppCompatActivity() { } } } - + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - + if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) { // Restore was successful, reload notes loadNotes() } } - + /** * v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität. - * + * * Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren * App-Versionen (v1.3.x) als leere Notiz angezeigt wird. - * + * * Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren, * damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden. - * + * * TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr * im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026). * Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX */ private fun migrateChecklistsForBackwardsCompat() { val migrationKey = "v1.4.1_checklist_migration_done" - + // Nur einmal ausführen if (prefs.getBoolean(migrationKey, false)) { return } - + val allNotes = storage.loadAllNotes() val checklistsToMigrate = allNotes.filter { note -> - note.noteType == NoteType.CHECKLIST && + note.noteType == NoteType.CHECKLIST && note.content.isBlank() && note.checklistItems?.isNotEmpty() == true } - + if (checklistsToMigrate.isNotEmpty()) { Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content") - + for (note in checklistsToMigrate) { - // Als PENDING markieren, damit beim nächsten Sync der Fallback-Content + // Als PENDING markieren, damit beim nächsten Sync der Fallback-Content // generiert und hochgeladen wird val updatedNote = note.copy( syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING @@ -792,24 +790,24 @@ class MainActivity : AppCompatActivity() { storage.saveNote(updatedNote) Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}") } - + Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync") } - + // Migration als erledigt markieren prefs.edit().putBoolean(migrationKey, true).apply() } - + override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - + when (requestCode) { REQUEST_NOTIFICATION_PERMISSION -> { - if (grantResults.isNotEmpty() && + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { showToast(getString(R.string.toast_notifications_enabled)) } else { @@ -818,39 +816,39 @@ class MainActivity : AppCompatActivity() { } } } - + /** * 🌍 v1.7.2: Debug-Logging für Locale-Problem * Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden */ private fun logLocaleInfo() { if (!BuildConfig.DEBUG) return - + Logger.d(TAG, "╔═══════════════════════════════════════════════════") Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO") Logger.d(TAG, "╠═══════════════════════════════════════════════════") - + // System Locale val systemLocale = java.util.Locale.getDefault() Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale") - + // Resources Locale val resourcesLocale = resources.configuration.locales[0] Logger.d(TAG, "║ Resources Locale: $resourcesLocale") - + // Context Locale (API 24+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val contextLocales = resources.configuration.locales Logger.d(TAG, "║ Context Locales (all): $contextLocales") } - + // Test String Loading val testString = getString(R.string.toast_already_synced) Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)") Logger.d(TAG, "║ Result: '$testString'") Logger.d(TAG, "║ Expected EN: '✅ Already synced'") Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}") - + Logger.d(TAG, "╚═══════════════════════════════════════════════════") } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt index 55c03f7..610e32f 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt @@ -6,18 +6,22 @@ import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.utils.NotificationHelper 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() { - + companion object { private const val TAG = "SimpleNotesApp" } - + lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity - + /** * 🌍 v1.7.1: Apply app locale to Application Context - * + * * This ensures ViewModels and other components using Application Context * get the correct locale-specific strings. */ @@ -26,71 +30,77 @@ class SimpleNotesApplication : Application() { // This is handled by AppCompatDelegate which reads from system storage super.attachBaseContext(base) } - + override fun 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) - + // 🔧 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 // appear as offline even though they have a configured server migrateOfflineModeSetting(prefs) - + // File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!) if (prefs.getBoolean("file_logging_enabled", false)) { Logger.enableFileLogging(this) Logger.d(TAG, "📝 File logging enabled at Application startup") } - + Logger.d(TAG, "🚀 Application onCreate()") - + // Initialize notification channel NotificationHelper.createNotificationChannel(this) Logger.d(TAG, "✅ Notification channel created") - + // Initialize NetworkMonitor (WorkManager-based) // VORTEIL: WorkManager läuft auch ohne aktive App! networkMonitor = NetworkMonitor(applicationContext) - + // Start WorkManager periodic sync // Dies läuft im Hintergrund auch wenn App geschlossen ist networkMonitor.startMonitoring() - + Logger.d(TAG, "✅ WorkManager-based auto-sync initialized") } - + override fun onTerminate() { super.onTerminate() - + Logger.d(TAG, "🛑 Application onTerminate()") - + // WorkManager läuft weiter auch nach onTerminate! // Nur bei deaktiviertem Auto-Sync stoppen wir es } - + /** * 🔧 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 - * and NoteEditorViewModel use `true` as default, causing existing users + * + * Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel + * and NoteEditorViewModel use `true` as default, causing existing users * with configured servers to appear in offline mode after update. - * + * * Fix: Set the key BEFORE any ViewModel is initialized based on whether * a server was already configured. */ private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) { if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) { val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) - val hasServerConfig = !serverUrl.isNullOrEmpty() && - serverUrl != "http://" && + val hasServerConfig = !serverUrl.isNullOrEmpty() && + serverUrl != "http://" && serverUrl != "https://" - + // If server was configured → offlineMode = false (continue syncing) // If no server → offlineMode = true (new users / offline users) val offlineModeValue = !hasServerConfig prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply() - + Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue") } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/di/AppModule.kt b/android/app/src/main/java/dev/dettmer/simplenotes/di/AppModule.kt new file mode 100644 index 0000000..1fbb708 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/di/AppModule.kt @@ -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().noteDao() } + single { get().deletedNoteDao() } + + // Provide SharedPreferences + single { + androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) + } + + single { NotesStorage(androidContext(), get()) } + + viewModel { MainViewModel(get(), get()) } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/AppDatabase.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/AppDatabase.kt new file mode 100644 index 0000000..3e215c8 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/AppDatabase.kt @@ -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 +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt index 027b64f..baf7926 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt @@ -1,77 +1,59 @@ package dev.dettmer.simplenotes.storage import android.content.Context -import dev.dettmer.simplenotes.models.DeletionTracker -import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.SyncStatus +import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity +import dev.dettmer.simplenotes.storage.entity.NoteEntity import dev.dettmer.simplenotes.utils.DeviceIdGenerator 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 { private const val TAG = "NotesStorage" - // 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen - private val deletionTrackerMutex = Mutex() } - - private val notesDir: File = File(context.filesDir, "notes").apply { - if (!exists()) mkdirs() + + private val noteDao = database.noteDao() + private val deletedNoteDao = database.deletedNoteDao() + + suspend fun saveNote(note: NoteEntity) { + noteDao.saveNote(note) } - - fun saveNote(note: Note) { - val file = File(notesDir, "${note.id}.json") - file.writeText(note.toJson()) + + suspend fun loadNote(id: String): NoteEntity? { + return noteDao.getNote(id) } - - fun loadNote(id: String): Note? { - val file = File(notesDir, "$id.json") - return if (file.exists()) { - Note.fromJson(file.readText()) - } else { - null - } + + suspend fun loadAllNotes(): List { + return noteDao.getAllNotes() } - - /** - * Lädt alle Notizen aus dem lokalen Speicher. - * - * 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt, - * damit der User die Sortierung konfigurieren kann. - */ - fun loadAllNotes(): List { - 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) { + + suspend fun deleteNote(id: String): Boolean { + val deletedRows = noteDao.deleteNoteById(id) + + if (deletedRows > 0) { Logger.d(TAG, "🗑️ Deleted note: $id") - - // Track deletion to prevent zombie notes val deviceId = DeviceIdGenerator.getDeviceId(context) - trackDeletion(id, deviceId) + trackDeletionSafe(id, deviceId) + return true } - - return deleted + return false } - - fun deleteAllNotes(): Boolean { + + suspend fun deleteAllNotes(): Boolean { return try { val notes = loadAllNotes() val deviceId = DeviceIdGenerator.getDeviceId(context) - - for (note in notes) { - deleteNote(note.id) // Uses trackDeletion() automatically + + // Batch tracking and deleting + notes.forEach { note -> + trackDeletionSafe(note.id, deviceId) } - + noteDao.deleteAllNotes() + Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)") true } catch (e: Exception) { @@ -79,104 +61,31 @@ class NotesStorage(private val context: Context) { false } } - + // === 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) { - deletionTrackerMutex.withLock { - val tracker = loadDeletionTracker() - tracker.addDeletion(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) + // Room handles internal transactions and thread-safety natively. + // The Mutex is no longer required. + deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId)) Logger.d(TAG, "📝 Tracked deletion: $noteId") } - - fun isNoteDeleted(noteId: String): Boolean { - val tracker = loadDeletionTracker() - return tracker.isDeleted(noteId) + + suspend fun isNoteDeleted(noteId: String): Boolean { + return deletedNoteDao.isNoteDeleted(noteId) } - - fun clearDeletionTracker() { - saveDeletionTracker(DeletionTracker()) + + suspend fun clearDeletionTracker() { + deletedNoteDao.clearTracker() Logger.d(TAG, "🗑️ Deletion tracker cleared") } - - /** - * 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes - * This ensures notes are uploaded to the new server on next sync - */ - fun resetAllSyncStatusToPending(): Int { - val notes = loadAllNotes() - var updatedCount = 0 - - notes.forEach { note -> - if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) { - val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING) - saveNote(updatedNote) - updatedCount++ - } - } - + + suspend fun resetAllSyncStatusToPending(): Int { + val updatedCount = noteDao.updateSyncStatus( + oldStatus = SyncStatus.SYNCED, + newStatus = SyncStatus.PENDING + ) Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING") return updatedCount } - - - fun getNotesDir(): File = notesDir } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/DeletedNoteDao.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/DeletedNoteDao.kt new file mode 100644 index 0000000..fa7eb7b --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/DeletedNoteDao.kt @@ -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() +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/NoteDao.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/NoteDao.kt new file mode 100644 index 0000000..af98638 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/NoteDao.kt @@ -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 + + @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 +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/DeletedNoteEntity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/DeletedNoteEntity.kt new file mode 100644 index 0000000..9cfce0a --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/DeletedNoteEntity.kt @@ -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() +) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/NoteEntity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/NoteEntity.kt new file mode 100644 index 0000000..10ede74 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/NoteEntity.kt @@ -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 +) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt index e965120..fa5c6e4 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt @@ -44,11 +44,12 @@ import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.NotificationHelper import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.viewModel /** * Main Activity with Jetpack Compose UI * v1.5.0: Complete MainActivity Redesign with Compose - * + * * Replaces the old 805-line MainActivity.kt with a modern * Compose-based implementation featuring: * - Notes list with swipe-to-delete @@ -58,22 +59,22 @@ import kotlinx.coroutines.launch * - Design consistent with ComposeSettingsActivity */ class ComposeMainActivity : ComponentActivity() { - + companion object { private const val TAG = "ComposeMainActivity" private const val REQUEST_NOTIFICATION_PERMISSION = 1001 private const val REQUEST_SETTINGS = 1002 } - - private val viewModel: MainViewModel by viewModels() - + + private val viewModel: MainViewModel by viewModel() + private val prefs by lazy { getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) } - + // Phase 3: Track if coming from editor to scroll to top private var cameFromEditor = false - + /** * BroadcastReceiver for Background-Sync Completion (Periodic Sync) */ @@ -81,9 +82,9 @@ class ComposeMainActivity : ComponentActivity() { override fun onReceive(context: Context?, intent: Intent?) { val success = intent?.getBooleanExtra("success", false) ?: false val count = intent?.getIntExtra("count", 0) ?: 0 - + Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count") - + // UI refresh if (success && count > 0) { viewModel.loadNotes() @@ -91,60 +92,60 @@ class ComposeMainActivity : ComponentActivity() { } } } - + override fun onCreate(savedInstanceState: Bundle?) { // Install Splash Screen (Android 12+) installSplashScreen() - + super.onCreate(savedInstanceState) - + // Apply Dynamic Colors for Material You (Android 12+) DynamicColors.applyToActivityIfAvailable(this) - + // Enable edge-to-edge display enableEdgeToEdge() - + // Initialize Logger and enable file logging if configured Logger.init(this) if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) { Logger.setFileLoggingEnabled(true) } - + // Clear old sync notifications on app start NotificationHelper.clearSyncNotifications(this) - + // Request notification permission (Android 13+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { requestNotificationPermission() } - + // v1.4.1: Migrate checklists for backwards compatibility migrateChecklistsForBackwardsCompat() - + // Setup Sync State Observer setupSyncStateObserver() - + setContent { SimpleNotesTheme { val context = LocalContext.current - + // Dialog state for delete confirmation var deleteDialogData by remember { mutableStateOf(null) } - + // Handle delete dialog events LaunchedEffect(Unit) { viewModel.showDeleteDialog.collect { data -> deleteDialogData = data } } - + // Handle toast events LaunchedEffect(Unit) { viewModel.showToast.collect { message -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } - + // Delete confirmation dialog deleteDialogData?.let { data -> DeleteConfirmationDialog( @@ -163,70 +164,70 @@ class ComposeMainActivity : ComponentActivity() { } ) } - + MainScreen( viewModel = viewModel, onOpenNote = { noteId -> openNoteEditor(noteId) }, onOpenSettings = { openSettings() }, onCreateNote = { noteType -> createNote(noteType) } ) - + // v1.8.0: Post-Update Changelog (shows once after update) UpdateChangelogSheet() } } } - + override fun onResume() { super.onResume() - + Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers") - + // 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks) // This ensures UI reflects current offline mode when returning from Settings viewModel.refreshOfflineModeState() - + // 🎨 v1.7.0: Refresh display mode when returning from Settings viewModel.refreshDisplayMode() - + // Register BroadcastReceiver for Background-Sync @Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional LocalBroadcastManager.getInstance(this).registerReceiver( syncCompletedReceiver, IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED) ) - + Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)") - + // Reload notes viewModel.loadNotes() - + // Phase 3: Scroll to top if coming from editor (new/edited note) if (cameFromEditor) { viewModel.scrollToTop() cameFromEditor = false Logger.d(TAG, "📜 Came from editor - scrolling to top") } - + // Trigger Auto-Sync on app resume viewModel.triggerAutoSync("onResume") } - + override fun onPause() { super.onPause() - + // Unregister BroadcastReceiver @Suppress("DEPRECATION") LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver) Logger.d(TAG, "📡 BroadcastReceiver unregistered") } - + private fun setupSyncStateObserver() { // 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern) SyncStateManager.syncStatus.observe(this) { status -> viewModel.updateSyncState(status) } - + // 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System) lifecycleScope.launch { SyncStateManager.syncProgress.collect { progress -> @@ -250,14 +251,14 @@ class ComposeMainActivity : ComponentActivity() { } } } - + private fun openNoteEditor(noteId: String?) { cameFromEditor = true val intent = Intent(this, ComposeNoteEditorActivity::class.java) noteId?.let { intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it) } - + // v1.5.0: Add slide animation val options = ActivityOptions.makeCustomAnimation( this, @@ -266,12 +267,12 @@ class ComposeMainActivity : ComponentActivity() { ) startActivity(intent, options.toBundle()) } - + private fun createNote(noteType: NoteType) { cameFromEditor = true val intent = Intent(this, ComposeNoteEditorActivity::class.java) intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name) - + // v1.5.0: Add slide animation val options = ActivityOptions.makeCustomAnimation( this, @@ -280,7 +281,7 @@ class ComposeMainActivity : ComponentActivity() { ) startActivity(intent, options.toBundle()) } - + private fun openSettings() { val intent = Intent(this, ComposeSettingsActivity::class.java) val options = ActivityOptions.makeCustomAnimation( @@ -291,10 +292,10 @@ class ComposeMainActivity : ComponentActivity() { @Suppress("DEPRECATION") startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle()) } - + private fun requestNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { requestPermissions( arrayOf(Manifest.permission.POST_NOTIFICATIONS), @@ -303,29 +304,29 @@ class ComposeMainActivity : ComponentActivity() { } } } - + /** * v1.4.1: Migrates existing checklists for backwards compatibility. */ private fun migrateChecklistsForBackwardsCompat() { val migrationKey = "v1.4.1_checklist_migration_done" - + // Only run once if (prefs.getBoolean(migrationKey, false)) { return } - + val storage = NotesStorage(this) val allNotes = storage.loadAllNotes() val checklistsToMigrate = allNotes.filter { note -> - note.noteType == NoteType.CHECKLIST && + note.noteType == NoteType.CHECKLIST && note.content.isBlank() && note.checklistItems?.isNotEmpty() == true } - + if (checklistsToMigrate.isNotEmpty()) { Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content") - + for (note in checklistsToMigrate) { val updatedNote = note.copy( syncStatus = SyncStatus.PENDING @@ -333,24 +334,24 @@ class ComposeMainActivity : ComponentActivity() { storage.saveNote(updatedNote) Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}") } - + Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync") } - + // Mark migration as done prefs.edit().putBoolean(migrationKey, true).apply() } - + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - + if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) { // Settings changed, reload notes viewModel.loadNotes() } } - + @Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts")) @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun onRequestPermissionsResult( @@ -359,15 +360,15 @@ class ComposeMainActivity : ComponentActivity() { grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - + when (requestCode) { REQUEST_NOTIFICATION_PERMISSION -> { - if (grantResults.isNotEmpty() && + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show() } else { - Toast.makeText(this, - getString(R.string.toast_notifications_disabled), + Toast.makeText(this, + getString(R.string.toast_notifications_disabled), Toast.LENGTH_SHORT ).show() } @@ -389,8 +390,8 @@ private fun DeleteConfirmationDialog( AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.legacy_delete_dialog_title)) }, - text = { - Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle)) + text = { + Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle)) }, dismissButton = { TextButton(onClick = onDismiss) { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt index c2561e3..de2f5b4 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt @@ -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.SyncStatusLegendDialog import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L /** * Main screen displaying the notes list * v1.5.0: Jetpack Compose MainActivity Redesign - * + * * Performance optimized with proper state handling: * - LazyListState for scroll control * - Scaffold FAB slot for proper z-ordering @@ -74,7 +75,7 @@ private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( - viewModel: MainViewModel, + viewModel: MainViewModel = koinViewModel(), onOpenNote: (String?) -> Unit, onOpenSettings: () -> Unit, onCreateNote: (NoteType) -> Unit @@ -82,37 +83,37 @@ fun MainScreen( val notes by viewModel.sortedNotes.collectAsState() val syncState by viewModel.syncState.collectAsState() val scrollToTop by viewModel.scrollToTop.collectAsState() - + // 🆕 v1.8.0: Einziges Banner-System val syncProgress by viewModel.syncProgress.collectAsState() - + // Multi-Select State val selectedNotes by viewModel.selectedNotes.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState() - + // 🌟 v1.6.0: Reactive offline mode state val isOfflineMode by viewModel.isOfflineMode.collectAsState() - + // 🎨 v1.7.0: Display mode (list or grid) val displayMode by viewModel.displayMode.collectAsState() - + // Delete confirmation dialog state var showBatchDeleteDialog by remember { mutableStateOf(false) } - + // 🆕 v1.8.0: Sync status legend dialog var showSyncLegend by remember { mutableStateOf(false) } - + // 🔀 v1.8.0: Sort dialog state var showSortDialog by remember { mutableStateOf(false) } val sortOption by viewModel.sortOption.collectAsState() val sortDirection by viewModel.sortDirection.collectAsState() - + val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val listState = rememberLazyListState() // 🎨 v1.7.0: gridState für Staggered Grid Layout val gridState = rememberLazyStaggeredGridState() - + // ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times var timestampTicker by remember { mutableStateOf(0L) } LaunchedEffect(Unit) { @@ -121,17 +122,17 @@ fun MainScreen( timestampTicker = System.currentTimeMillis() } } - + // Compute isSyncing once val isSyncing = syncState == SyncStateManager.SyncState.SYNCING - + // 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes) // Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState() // which is called in ComposeMainActivity.onResume() when returning from Settings val hasServerConfig = viewModel.hasServerConfig() val isSyncAvailable = !isOfflineMode && hasServerConfig val canSync = isSyncAvailable && !isSyncing - + // Handle snackbar events from ViewModel LaunchedEffect(Unit) { viewModel.showSnackbar.collect { data -> @@ -147,7 +148,7 @@ fun MainScreen( } } } - + // Phase 3: Scroll to top when new note created // 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid) LaunchedEffect(scrollToTop) { @@ -160,7 +161,7 @@ fun MainScreen( viewModel.resetScrollToTop() } } - + // v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit Scaffold( topBar = { @@ -213,7 +214,7 @@ fun MainScreen( progress = syncProgress, modifier = Modifier.fillMaxWidth() ) - + // Content: Empty state or notes list if (notes.isEmpty()) { EmptyState(modifier = Modifier.weight(1f)) @@ -249,7 +250,7 @@ fun MainScreen( listState = listState, modifier = Modifier.weight(1f), onNoteClick = { note -> onOpenNote(note.id) }, - onNoteLongPress = { note -> + onNoteLongPress = { note -> // Long-press starts selection mode viewModel.startSelectionMode(note.id) }, @@ -260,7 +261,7 @@ fun MainScreen( } } } - + // FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode AnimatedVisibility( visible = !isSelectionMode, @@ -277,7 +278,7 @@ fun MainScreen( } } } - + // Batch Delete Confirmation Dialog if (showBatchDeleteDialog) { DeleteConfirmationDialog( @@ -294,14 +295,14 @@ fun MainScreen( } ) } - + // 🆕 v1.8.0: Sync Status Legend Dialog if (showSyncLegend) { SyncStatusLegendDialog( onDismiss = { showSyncLegend = false } ) } - + // 🔀 v1.8.0: Sort Dialog if (showSortDialog) { SortDialog( @@ -344,7 +345,7 @@ private fun MainTopBar( contentDescription = stringResource(R.string.sort_notes) ) } - + // 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar) if (showSyncLegend) { IconButton(onClick = onSyncLegendClick) { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt index a850d16..1e0316b 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt @@ -2,7 +2,9 @@ package dev.dettmer.simplenotes.ui.main import android.app.Application import android.content.Context +import android.content.SharedPreferences import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.SortDirection @@ -31,50 +33,50 @@ import kotlinx.coroutines.withContext /** * ViewModel for MainActivity Compose * v1.5.0: Jetpack Compose MainActivity Redesign - * + * * 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 { private const val TAG = "MainViewModel" 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 val storage = NotesStorage(application) - private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) - + // ═══════════════════════════════════════════════════════════════════════ // Notes State // ═══════════════════════════════════════════════════════════════════════ - + private val _notes = MutableStateFlow>(emptyList()) val notes: StateFlow> = _notes.asStateFlow() - + private val _pendingDeletions = MutableStateFlow>(emptySet()) val pendingDeletions: StateFlow> = _pendingDeletions.asStateFlow() - + // ═══════════════════════════════════════════════════════════════════════ // Multi-Select State (v1.5.0) // ═══════════════════════════════════════════════════════════════════════ - + private val _selectedNotes = MutableStateFlow>(emptySet()) val selectedNotes: StateFlow> = _selectedNotes.asStateFlow() - + val isSelectionMode: StateFlow = _selectedNotes .map { it.isNotEmpty() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - + // ═══════════════════════════════════════════════════════════════════════ // 🌟 v1.6.0: Offline Mode State (reactive) // ═══════════════════════════════════════════════════════════════════════ - + private val _isOfflineMode = MutableStateFlow( prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true) ) val isOfflineMode: StateFlow = _isOfflineMode.asStateFlow() - + /** * Refresh offline mode state from SharedPreferences * Called when returning from Settings screen (in onResume) @@ -85,16 +87,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _isOfflineMode.value = newValue Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue") } - + // ═══════════════════════════════════════════════════════════════════════ // 🎨 v1.7.0: Display Mode State // ═══════════════════════════════════════════════════════════════════════ - + private val _displayMode = MutableStateFlow( prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE ) val displayMode: StateFlow = _displayMode.asStateFlow() - + /** * Refresh display mode from SharedPreferences * Called when returning from Settings screen @@ -104,25 +106,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _displayMode.value = newValue Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value} → $newValue") } - + // ═══════════════════════════════════════════════════════════════════════ // 🔀 v1.8.0: Sort State // ═══════════════════════════════════════════════════════════════════════ - + private val _sortOption = MutableStateFlow( SortOption.fromPrefsValue( prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION ) ) val sortOption: StateFlow = _sortOption.asStateFlow() - + private val _sortDirection = MutableStateFlow( SortDirection.fromPrefsValue( prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION ) ) val sortDirection: StateFlow = _sortDirection.asStateFlow() - + /** * 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection. * Reagiert automatisch auf Änderungen in allen drei Flows. @@ -138,68 +140,68 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) - + // ═══════════════════════════════════════════════════════════════════════ // Sync State // ═══════════════════════════════════════════════════════════════════════ - + // 🆕 v1.8.0: Einziges Banner-System - SyncProgress val syncProgress: StateFlow = SyncStateManager.syncProgress - + // Intern: SyncState für PullToRefresh-Indikator private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE) val syncState: StateFlow = _syncState.asStateFlow() - + // ═══════════════════════════════════════════════════════════════════════ // UI Events // ═══════════════════════════════════════════════════════════════════════ - + private val _showToast = MutableSharedFlow() val showToast: SharedFlow = _showToast.asSharedFlow() - + private val _showDeleteDialog = MutableSharedFlow() val showDeleteDialog: SharedFlow = _showDeleteDialog.asSharedFlow() - + private val _showSnackbar = MutableSharedFlow() val showSnackbar: SharedFlow = _showSnackbar.asSharedFlow() - + // Phase 3: Scroll-to-top when new note is created private val _scrollToTop = MutableStateFlow(false) val scrollToTop: StateFlow = _scrollToTop.asStateFlow() - + // Track first note ID to detect new notes private var previousFirstNoteId: String? = null - + // ═══════════════════════════════════════════════════════════════════════ // Data Classes // ═══════════════════════════════════════════════════════════════════════ - + data class DeleteDialogData( val note: Note, val originalList: List ) - + data class SnackbarData( val message: String, val actionLabel: String, val onAction: () -> Unit ) - + // ═══════════════════════════════════════════════════════════════════════ // Initialization // ═══════════════════════════════════════════════════════════════════════ - + init { // v1.5.0 Performance: Load notes asynchronously to avoid blocking UI viewModelScope.launch(Dispatchers.IO) { loadNotesAsync() } } - + // ═══════════════════════════════════════════════════════════════════════ // Notes Actions // ═══════════════════════════════════════════════════════════════════════ - + /** * Load notes asynchronously on IO dispatcher * This prevents UI blocking during app startup @@ -207,24 +209,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private suspend fun loadNotesAsync() { val allNotes = storage.loadAllNotes() 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) { // Phase 3: Detect if a new note was added at the top val newFirstNoteId = filteredNotes.firstOrNull()?.id - if (newFirstNoteId != null && - previousFirstNoteId != null && + if (newFirstNoteId != null && + previousFirstNoteId != null && newFirstNoteId != previousFirstNoteId) { // New note at top → trigger scroll _scrollToTop.value = true Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top") } previousFirstNoteId = newFirstNoteId - + _notes.value = filteredNotes } } - + /** * Public loadNotes - delegates to async version */ @@ -233,25 +238,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { loadNotesAsync() } } - + /** * Reset scroll-to-top flag after scroll completed */ fun resetScrollToTop() { _scrollToTop.value = false } - + /** * Force scroll to top (e.g., after returning from editor) */ fun scrollToTop() { _scrollToTop.value = true } - + // ═══════════════════════════════════════════════════════════════════════ // Multi-Select Actions (v1.5.0) // ═══════════════════════════════════════════════════════════════════════ - + /** * Toggle selection of a note */ @@ -262,56 +267,56 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _selectedNotes.value + noteId } } - + /** * Start selection mode with initial note */ fun startSelectionMode(noteId: String) { _selectedNotes.value = setOf(noteId) } - + /** * Select all notes */ fun selectAllNotes() { _selectedNotes.value = _notes.value.map { it.id }.toSet() } - + /** * Clear selection and exit selection mode */ fun clearSelection() { _selectedNotes.value = emptySet() } - + /** * Get count of selected notes */ fun getSelectedCount(): Int = _selectedNotes.value.size - + /** * Delete all selected notes */ fun deleteSelectedNotes(deleteFromServer: Boolean) { val selectedIds = _selectedNotes.value.toList() val selectedNotes = _notes.value.filter { it.id in selectedIds } - + if (selectedNotes.isEmpty()) return - + // Add to pending deletions _pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet() - + // Delete from storage selectedNotes.forEach { note -> storage.deleteNote(note.id) } - + // Clear selection clearSelection() - + // Reload notes loadNotes() - + // Show snackbar with undo val count = selectedNotes.size val message = if (deleteFromServer) { @@ -319,7 +324,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } else { getString(R.string.snackbar_notes_deleted_local, count) } - + viewModelScope.launch { _showSnackbar.emit(SnackbarData( message = message, @@ -328,7 +333,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { undoDeleteMultiple(selectedNotes) } )) - + @Suppress("MagicNumber") // Snackbar timing coordination // If delete from server, actually delete after a short delay // (to allow undo action before server deletion) @@ -347,19 +352,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + /** * Undo deletion of multiple notes */ private fun undoDeleteMultiple(notes: List) { // Remove from pending deletions _pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet() - + // Restore to storage notes.forEach { note -> storage.saveNote(note) } - + // Reload notes loadNotes() } @@ -370,10 +375,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { */ fun onNoteLongPressDelete(note: Note) { val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false) - + // Store original list for potential restore val originalList = _notes.value.toList() - + if (alwaysDeleteFromServer) { // Auto-delete without dialog deleteNoteConfirmed(note, deleteFromServer = true) @@ -392,34 +397,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun onNoteSwipedToDelete(note: Note) { onNoteLongPressDelete(note) // Delegate to long-press handler } - + /** * Restore note after swipe (user cancelled dialog) */ fun restoreNoteAfterSwipe(originalList: List) { _notes.value = originalList } - + /** * Confirm note deletion (from dialog or auto-delete) */ fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) { // Add to pending deletions _pendingDeletions.value = _pendingDeletions.value + note.id - + // Delete from storage storage.deleteNote(note.id) - + // Reload notes loadNotes() - + // Show snackbar with undo val message = if (deleteFromServer) { getString(R.string.snackbar_note_deleted_server, note.title) } else { getString(R.string.snackbar_note_deleted_local, note.title) } - + viewModelScope.launch { _showSnackbar.emit(SnackbarData( message = message, @@ -428,7 +433,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { undoDelete(note) } )) - + @Suppress("MagicNumber") // Snackbar timing // If delete from server, actually delete after snackbar timeout if (deleteFromServer) { @@ -443,21 +448,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + /** * Undo note deletion */ fun undoDelete(note: Note) { // Remove from pending deletions _pendingDeletions.value = _pendingDeletions.value - note.id - + // Restore to storage storage.saveNote(note) - + // Reload notes loadNotes() } - + /** * Actually delete note from server after snackbar dismissed */ @@ -468,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val success = withContext(Dispatchers.IO) { webdavService.deleteNoteFromServer(noteId) } - + if (success) { // 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO 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 * 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()) var successCount = 0 var failCount = 0 - + noteIds.forEach { noteId -> try { val success = withContext(Dispatchers.IO) { @@ -507,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _pendingDeletions.value = _pendingDeletions.value - noteId } } - + // 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR val message = when { 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) */ fun finalizeDeletion(noteId: String) { _pendingDeletions.value = _pendingDeletions.value - noteId } - + // ═══════════════════════════════════════════════════════════════════════ // Sync Actions // ═══════════════════════════════════════════════════════════════════════ - + fun updateSyncState(status: SyncStateManager.SyncStatus) { _syncState.value = status.state } - + /** * Trigger manual sync (from toolbar button or pull-to-refresh) * v1.7.0: Uses central canSync() gate for WiFi-only check @@ -559,14 +564,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } return } - + // 🆕 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) val prefs = getApplication().getSharedPreferences( Constants.PREFS_NAME, android.content.Context.MODE_PRIVATE ) - + // 🆕 v1.7.0: Feedback wenn Sync bereits läuft // 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant if (!SyncStateManager.tryStartSync(source)) { @@ -582,10 +587,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } return } - + // 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch) SyncStateManager.markGlobalSyncStarted(prefs) - + viewModelScope.launch { try { // Check for unsynced changes (Banner zeigt bereits PREPARING) @@ -595,23 +600,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { loadNotes() return@launch } - + // Check server reachability val isReachable = withContext(Dispatchers.IO) { syncService.isServerReachable() } - + if (!isReachable) { Logger.d(TAG, "⏭️ $source Sync: Server not reachable") SyncStateManager.markError(getString(R.string.snackbar_server_unreachable)) return@launch } - + // Perform sync val result = withContext(Dispatchers.IO) { syncService.syncNotes() } - + if (result.isSuccess) { // 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen val bannerMessage = buildString { @@ -636,7 +641,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + /** * Trigger auto-sync (onResume) * 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") return } - + // 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen) if (!SyncStateManager.canSyncGlobally(prefs)) { return } - + // Throttling check (eigener 60s-Cooldown für onResume) if (!canTriggerAutoSync()) { return } - + // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config) val syncService = WebDavSyncService(getApplication()) val gateResult = syncService.canSync() @@ -672,22 +677,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } return } - + // v1.5.0: silent=true → kein Banner bei Auto-Sync // 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) { Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress") return } - + Logger.d(TAG, "🔄 Auto-sync triggered ($source)") - + // Update last sync timestamp prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply() - + // 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren SyncStateManager.markGlobalSyncStarted(prefs) - + viewModelScope.launch { try { // Check for unsynced changes @@ -696,23 +701,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { SyncStateManager.reset() // Silent → geht direkt auf IDLE return@launch } - + // Check server reachability val isReachable = withContext(Dispatchers.IO) { syncService.isServerReachable() } - + if (!isReachable) { Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") SyncStateManager.reset() // Silent → kein Error-Banner return@launch } - + // Perform sync val result = withContext(Dispatchers.IO) { syncService.syncNotes() } - + if (result.isSuccess && result.syncedCount > 0) { Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") // 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync @@ -734,25 +739,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + private fun canTriggerAutoSync(): Boolean { val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0) val now = System.currentTimeMillis() val timeSinceLastSync = now - lastSyncTime - + if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) { val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000 Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s") return false } - + return true } - + // ═══════════════════════════════════════════════════════════════════════ // 🔀 v1.8.0: Sortierung // ═══════════════════════════════════════════════════════════════════════ - + /** * 🔀 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 { it.noteType.ordinal } .thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen } - + return when (direction) { SortDirection.ASCENDING -> notes.sortedWith(comparator) SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed()) } } - + /** * 🔀 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() Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}") } - + /** * 🔀 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() Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}") } - + /** * 🔀 v1.8.0: Toggelt die Sortierrichtung. */ @@ -800,16 +805,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val newDirection = _sortDirection.value.toggle() setSortDirection(newDirection) } - + // ═══════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════ - + private fun getString(resId: Int): String = getApplication().getString(resId) - - private fun getString(resId: Int, vararg formatArgs: Any): String = + + private fun getString(resId: Int, vararg formatArgs: Any): String = getApplication().getString(resId, *formatArgs) - + fun isServerConfigured(): Boolean { // 🌟 v1.6.0: Use reactive offline mode state if (_isOfflineMode.value) { @@ -818,7 +823,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://" } - + /** * 🌟 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 diff --git a/android/app/src/test/java/dev/dettmer/simplenotes/ExampleUnitTest.kt b/android/app/src/test/java/dev/dettmer/simplenotes/ExampleUnitTest.kt deleted file mode 100644 index 9b9b9c4..0000000 --- a/android/app/src/test/java/dev/dettmer/simplenotes/ExampleUnitTest.kt +++ /dev/null @@ -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) - } -} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 255babb..0e7c33b 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -16,6 +16,9 @@ composeBom = "2026.01.00" navigationCompose = "2.7.6" lifecycleRuntimeCompose = "2.7.0" activityCompose = "1.8.2" +room = "2.6.1" +ksp = "2.0.0-1.0.21" +koin = "3.5.3" [libraries] 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-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" } +# 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] 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" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }