3 Commits

Author SHA1 Message Date
f0ae34cdaa Removed the JSON storage and opted for room database
Some checks failed
PR Build Check / Build & Test APK (pull_request) Has been cancelled
2026-04-09 19:07:00 +01:00
d868c532b7 mid commit 2026-03-23 23:41:05 +00:00
7ddad7e5e7 mid commit 2026-02-18 00:45:02 +00:00
27 changed files with 1440 additions and 2068 deletions

View File

@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
alias(libs.plugins.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<Copy>("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<Copy>("copyChangelogsToAssets") {
relativePath = RelativePath(true, parts[0], parts[2])
}
}
includeEmptyDirs = false
}
// Run before preBuild to ensure changelogs are available
tasks.named("preBuild") {
dependsOn("copyChangelogsToAssets")
}
}

View File

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

View File

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

View File

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

View File

@@ -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")
}
}

View File

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

View File

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

View File

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

View File

@@ -3,75 +3,99 @@ package dev.dettmer.simplenotes.storage
import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
import dev.dettmer.simplenotes.storage.dao.NoteDao
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.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,
private val noteDao: NoteDao,
private val deletedNoteDao: DeletedNoteDao,
) {
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()
suspend fun saveNote(note: Note) {
noteDao.saveNote(
NoteEntity(
id = note.id,
title = note.title,
content = note.content,
createdAt = note.createdAt,
updatedAt = note.updatedAt,
deviceId = note.deviceId,
syncStatus = note.syncStatus,
noteType = note.noteType,
checklistItems = note.checklistItems,
checklistSortOption = note.checklistSortOption
)
)
}
fun saveNote(note: Note) {
val file = File(notesDir, "${note.id}.json")
file.writeText(note.toJson())
}
fun loadNote(id: String): Note? {
val file = File(notesDir, "$id.json")
return if (file.exists()) {
Note.fromJson(file.readText())
} else {
null
suspend fun loadNote(id: String): Note? {
return noteDao.getNote(id)?.let { note ->
Note(
id = note.id,
title = note.title,
content = note.content,
createdAt = note.createdAt,
updatedAt = note.updatedAt,
deviceId = note.deviceId,
syncStatus = note.syncStatus,
noteType = note.noteType,
checklistItems = note.checklistItems,
checklistSortOption = note.checklistSortOption
)
}
}
/**
* 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<Note> {
return notesDir.listFiles()
?.filter { it.extension == "json" }
?.mapNotNull { Note.fromJson(it.readText()) }
?: emptyList()
suspend fun loadAllNotes(): List<Note> {
return noteDao.getAllNotes().map { note ->
Note(
id = note.id,
title = note.title,
content = note.content,
createdAt = note.createdAt,
updatedAt = note.updatedAt,
deviceId = note.deviceId,
syncStatus = note.syncStatus,
noteType = note.noteType,
checklistItems = note.checklistItems,
checklistSortOption = note.checklistSortOption
)
}
}
fun deleteNote(id: String): Boolean {
val file = File(notesDir, "$id.json")
val deleted = file.delete()
suspend fun deleteNote(id: String): Boolean {
val deleted = noteDao.deleteNoteById(id) > 0
if (deleted) {
Logger.d(TAG, "🗑️ Deleted note: $id")
// Track deletion to prevent zombie notes
val deviceId = DeviceIdGenerator.getDeviceId(context)
trackDeletion(id, deviceId)
deletedNoteDao.trackDeletion(DeletedNoteEntity(id, deviceId))
}
return deleted
}
fun deleteAllNotes(): Boolean {
suspend fun deleteAllNotes(): Boolean {
return try {
val notes = loadAllNotes()
val deviceId = DeviceIdGenerator.getDeviceId(context)
val notes = noteDao.getAllNotes()
noteDao.deleteAllNotes()
for (note in notes) {
deleteNote(note.id) // Uses trackDeletion() automatically
val deviceId = DeviceIdGenerator.getDeviceId(context)
deletedNoteDao.trackDeletion(DeletedNoteEntity(note.id, deviceId))
}
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
true
} catch (e: Exception) {
@@ -79,19 +103,19 @@ 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()
@@ -100,83 +124,64 @@ class NotesStorage(private val context: Context) {
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")
}
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
}
/**
* 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)
suspend fun trackDeletion(noteId: String, deviceId: String) {
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 {
var updatedCount = noteDao.updateSyncStatus(SyncStatus.SYNCED, SyncStatus.PENDING)
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount
}
fun getNotesDir(): File = notesDir
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -44,11 +45,14 @@ import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.java.KoinJavaComponent.inject
import kotlin.getValue
/**
* Main Activity with Jetpack Compose UI
* 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 +62,21 @@ 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 prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
private val viewModel: MainViewModel by viewModel()
private val storage: NotesStorage by inject(NotesStorage::class.java)
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
// Phase 3: Track if coming from editor to scroll to top
private var cameFromEditor = false
/**
* BroadcastReceiver for Background-Sync Completion (Periodic Sync)
*/
@@ -81,9 +84,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 +94,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<MainViewModel.DeleteDialogData?>(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 +166,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 +253,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 +269,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 +283,7 @@ class ComposeMainActivity : ComponentActivity() {
)
startActivity(intent, options.toBundle())
}
private fun openSettings() {
val intent = Intent(this, ComposeSettingsActivity::class.java)
val options = ActivityOptions.makeCustomAnimation(
@@ -291,10 +294,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,54 +306,25 @@ 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.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true
}
if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) {
val updatedNote = note.copy(
syncStatus = SyncStatus.PENDING
)
storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
}
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
}
// Mark migration as done
prefs.edit().putBoolean(migrationKey, true).apply()
viewModel.migrateChecklistsForBackwardsCompat()
}
@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 +333,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 +363,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) {

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.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) {

View File

@@ -2,12 +2,15 @@ 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.viewModelScope
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SortDirection
import dev.dettmer.simplenotes.models.SortOption
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncProgress
import dev.dettmer.simplenotes.sync.SyncStateManager
@@ -27,54 +30,56 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.inject
import kotlin.getValue
/**
* 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) {
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)
private val storage: NotesStorage by inject(NotesStorage::class.java)
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
// ═══════════════════════════════════════════════════════════════════════
// Notes State
// ═══════════════════════════════════════════════════════════════════════
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Multi-Select State (v1.5.0)
// ═══════════════════════════════════════════════════════════════════════
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
val isSelectionMode: StateFlow<Boolean> = _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<Boolean> = _isOfflineMode.asStateFlow()
/**
* Refresh offline mode state from SharedPreferences
* Called when returning from Settings screen (in onResume)
@@ -85,16 +90,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<String> = _displayMode.asStateFlow()
/**
* Refresh display mode from SharedPreferences
* Called when returning from Settings screen
@@ -104,25 +109,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> = _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> = _sortDirection.asStateFlow()
/**
* 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection.
* Reagiert automatisch auf Änderungen in allen drei Flows.
@@ -138,68 +143,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<SyncProgress> = SyncStateManager.syncProgress
// Intern: SyncState für PullToRefresh-Indikator
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// UI Events
// ═══════════════════════════════════════════════════════════════════════
private val _showToast = MutableSharedFlow<String>()
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
private val _showSnackbar = MutableSharedFlow<SnackbarData>()
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
// Phase 3: Scroll-to-top when new note is created
private val _scrollToTop = MutableStateFlow(false)
val scrollToTop: StateFlow<Boolean> = _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<Note>
)
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
@@ -208,23 +213,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val allNotes = storage.loadAllNotes()
val pendingIds = _pendingDeletions.value
val filteredNotes = allNotes.filter { it.id !in pendingIds }
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) {
fun deleteSelectedNotes(deleteFromServer: Boolean) = viewModelScope.launch {
val selectedIds = _selectedNotes.value.toList()
val selectedNotes = _notes.value.filter { it.id in selectedIds }
if (selectedNotes.isEmpty()) return
if (selectedNotes.isEmpty()) return@launch
// 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<Note>) {
private fun undoDeleteMultiple(notes: List<Note>) = viewModelScope.launch{
// 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<Note>) {
_notes.value = originalList
}
/**
* Confirm note deletion (from dialog or auto-delete)
*/
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) = viewModelScope.launch{
// Add to pending deletions
_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) {
fun undoDelete(note: Note) = viewModelScope.launch{
// 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<android.app.Application>().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<Note> { 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<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)
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
@@ -827,4 +832,37 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
}
fun migrateChecklistsForBackwardsCompat() = viewModelScope.launch{
val migrationKey = "v1.4.1_checklist_migration_done"
// Only run once
if (prefs.getBoolean(migrationKey, false)) {
return@launch
}
val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true
}
if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) {
val updatedNote = note.copy(
syncStatus = SyncStatus.PENDING
)
storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
}
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
}
// Mark migration as done
prefs.edit().putBoolean(migrationKey, true).apply()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,9 @@ composeBom = "2026.01.00"
navigationCompose = "2.7.6"
lifecycleRuntimeCompose = "2.7.0"
activityCompose = "1.8.2"
room = "2.6.1"
ksp = "2.0.21-1.0.27"
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" }