mid commit
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ import android.widget.CheckBox
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
@@ -56,30 +54,30 @@ import dev.dettmer.simplenotes.models.NoteType
|
||||
*/
|
||||
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
|
||||
private lateinit var recyclerViewNotes: RecyclerView
|
||||
private lateinit var emptyStateCard: MaterialCardView
|
||||
private lateinit var fabAddNote: FloatingActionButton
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
||||
|
||||
|
||||
// 🔄 v1.3.1: Sync Status Banner
|
||||
private lateinit var syncStatusBanner: LinearLayout
|
||||
private lateinit var syncStatusText: TextView
|
||||
|
||||
|
||||
private lateinit var adapter: NotesAdapter
|
||||
private val storage by lazy { NotesStorage(this) }
|
||||
|
||||
|
||||
// Menu reference for sync button state
|
||||
private var optionsMenu: Menu? = null
|
||||
|
||||
|
||||
// Track pending deletions to prevent flicker when notes reload
|
||||
private val pendingDeletions = mutableSetOf<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
|
||||
@@ -89,7 +87,7 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val SYNC_COMPLETED_DELAY_MS = 1500L
|
||||
private const val ERROR_DISPLAY_DELAY_MS = 3000L
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* BroadcastReceiver für Background-Sync Completion (Periodic Sync)
|
||||
*/
|
||||
@@ -97,9 +95,9 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val success = intent?.getBooleanExtra("success", false) ?: false
|
||||
val count = intent?.getIntExtra("count", 0) ?: 0
|
||||
|
||||
|
||||
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
||||
|
||||
|
||||
// UI refresh
|
||||
if (success && count > 0) {
|
||||
loadNotes()
|
||||
@@ -107,49 +105,49 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install Splash Screen (Android 12+)
|
||||
installSplashScreen()
|
||||
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
// Apply Dynamic Colors for Android 12+ (Material You)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
|
||||
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
|
||||
// Alte Sync-Notifications beim App-Start löschen
|
||||
NotificationHelper.clearSyncNotifications(this)
|
||||
|
||||
|
||||
// Permission für Notifications (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
|
||||
// 🌍 v1.7.2: Debug Locale für Fehlersuche
|
||||
logLocaleInfo()
|
||||
|
||||
|
||||
findViews()
|
||||
setupToolbar()
|
||||
setupRecyclerView()
|
||||
setupFab()
|
||||
|
||||
|
||||
// v1.4.1: Migrate checklists for backwards compatibility
|
||||
migrateChecklistsForBackwardsCompat()
|
||||
|
||||
|
||||
loadNotes()
|
||||
|
||||
|
||||
// 🔄 v1.3.1: Observe sync state for UI updates
|
||||
setupSyncStateObserver()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
|
||||
*/
|
||||
@@ -200,7 +198,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
|
||||
*/
|
||||
@@ -210,32 +208,32 @@ class MainActivity : AppCompatActivity() {
|
||||
// SwipeRefresh
|
||||
swipeRefreshLayout.isEnabled = enabled
|
||||
}
|
||||
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
|
||||
Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers")
|
||||
|
||||
|
||||
// Register BroadcastReceiver für Background-Sync
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
syncCompletedReceiver,
|
||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
)
|
||||
|
||||
|
||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
||||
|
||||
|
||||
// Reload notes (scroll to top wird in loadNotes() gemacht)
|
||||
loadNotes()
|
||||
|
||||
|
||||
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
|
||||
triggerAutoSync("onResume")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Automatischer Sync (onResume)
|
||||
* - Nutzt WiFi-gebundenen Socket (VPN Fix!)
|
||||
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
|
||||
*
|
||||
*
|
||||
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
|
||||
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
||||
*/
|
||||
@@ -244,65 +242,65 @@ class MainActivity : AppCompatActivity() {
|
||||
if (!canTriggerAutoSync()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 🔄 v1.3.1: Check if sync already running
|
||||
// v1.5.0: silent=true - kein Banner bei Auto-Sync
|
||||
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||
|
||||
|
||||
// Update last sync timestamp
|
||||
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Server ist erreichbar → Sync durchführen
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
|
||||
// Feedback abhängig von Source
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
|
||||
|
||||
// onResume: Nur Success-Toast
|
||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
||||
loadNotes()
|
||||
|
||||
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
SyncStateManager.markCompleted()
|
||||
|
||||
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
@@ -310,7 +308,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Prüft ob Auto-Sync getriggert werden darf (Throttling)
|
||||
*/
|
||||
@@ -318,96 +316,96 @@ class MainActivity : AppCompatActivity() {
|
||||
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeSinceLastSync = now - lastSyncTime
|
||||
|
||||
|
||||
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
||||
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
|
||||
// Unregister BroadcastReceiver
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
||||
}
|
||||
|
||||
|
||||
private fun findViews() {
|
||||
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
|
||||
emptyStateCard = findViewById(R.id.emptyStateCard)
|
||||
fabAddNote = findViewById(R.id.fabAddNote)
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
swipeRefreshLayout = findViewById<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()
|
||||
@@ -420,13 +418,13 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set Material 3 color scheme
|
||||
swipeRefreshLayout.setColorSchemeResources(
|
||||
com.google.android.material.R.color.material_dynamic_primary50
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun setupSwipeToDelete() {
|
||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
0, // No drag
|
||||
@@ -437,45 +435,45 @@ class MainActivity : AppCompatActivity() {
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean = false
|
||||
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val position = viewHolder.bindingAdapterPosition
|
||||
val swipedNote = adapter.currentList[position]
|
||||
|
||||
|
||||
// Store original list BEFORE removing note
|
||||
val originalList = adapter.currentList.toList()
|
||||
|
||||
|
||||
// Remove from list for visual feedback (NOT from storage yet!)
|
||||
val listWithoutNote = originalList.toMutableList().apply {
|
||||
removeAt(position)
|
||||
}
|
||||
adapter.submitList(listWithoutNote)
|
||||
|
||||
|
||||
// Show dialog with ability to restore
|
||||
showServerDeletionDialog(swipedNote, originalList)
|
||||
}
|
||||
|
||||
|
||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
||||
// Require 80% swipe to trigger
|
||||
return 0.8f
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
||||
}
|
||||
|
||||
|
||||
private fun showServerDeletionDialog(note: Note, originalList: List<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))
|
||||
@@ -504,24 +502,24 @@ class MainActivity : AppCompatActivity() {
|
||||
.setCancelable(true)
|
||||
.show()
|
||||
}
|
||||
|
||||
|
||||
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
|
||||
// Track pending deletion to prevent flicker
|
||||
pendingDeletions.add(note.id)
|
||||
|
||||
|
||||
// Delete from storage
|
||||
storage.deleteNote(note.id)
|
||||
|
||||
|
||||
// Reload to reflect changes
|
||||
loadNotes()
|
||||
|
||||
|
||||
// Show Snackbar with UNDO option
|
||||
val message = if (deleteFromServer) {
|
||||
getString(R.string.legacy_delete_with_server, note.title)
|
||||
} else {
|
||||
getString(R.string.legacy_delete_local_only, note.title)
|
||||
}
|
||||
|
||||
|
||||
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
|
||||
.setAction(getString(R.string.snackbar_undo)) {
|
||||
// UNDO: Restore note
|
||||
@@ -534,7 +532,7 @@ class MainActivity : AppCompatActivity() {
|
||||
if (event != DISMISS_EVENT_ACTION) {
|
||||
// Snackbar dismissed without UNDO
|
||||
pendingDeletions.remove(note.id)
|
||||
|
||||
|
||||
// Delete from server if requested
|
||||
if (deleteFromServer) {
|
||||
lifecycleScope.launch {
|
||||
@@ -573,7 +571,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}).show()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
|
||||
*/
|
||||
@@ -582,14 +580,14 @@ class MainActivity : AppCompatActivity() {
|
||||
showNoteTypePopup(view)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
|
||||
*/
|
||||
private fun showNoteTypePopup(anchor: View) {
|
||||
val popupMenu = PopupMenu(this, anchor, Gravity.END)
|
||||
popupMenu.inflate(R.menu.menu_fab_note_types)
|
||||
|
||||
|
||||
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
|
||||
try {
|
||||
val fields = popupMenu.javaClass.declaredFields
|
||||
@@ -606,29 +604,29 @@ class MainActivity : AppCompatActivity() {
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
|
||||
}
|
||||
|
||||
|
||||
popupMenu.setOnMenuItemClickListener { menuItem ->
|
||||
val noteType = when (menuItem.itemId) {
|
||||
R.id.action_create_text_note -> NoteType.TEXT
|
||||
R.id.action_create_checklist -> NoteType.CHECKLIST
|
||||
else -> return@setOnMenuItemClickListener false
|
||||
}
|
||||
|
||||
|
||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
popupMenu.show()
|
||||
}
|
||||
|
||||
|
||||
private fun loadNotes() {
|
||||
val notes = storage.loadAllNotes()
|
||||
|
||||
|
||||
// Filter out notes that are pending deletion (prevent flicker)
|
||||
val filteredNotes = notes.filter { it.id !in pendingDeletions }
|
||||
|
||||
|
||||
// Submit list with callback to scroll to top after list is updated
|
||||
adapter.submitList(filteredNotes) {
|
||||
// Scroll to top after list update is complete
|
||||
@@ -637,7 +635,7 @@ class MainActivity : AppCompatActivity() {
|
||||
recyclerViewNotes.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Material 3 Empty State Card
|
||||
emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
|
||||
android.view.View.VISIBLE
|
||||
@@ -645,7 +643,7 @@ class MainActivity : AppCompatActivity() {
|
||||
android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun openNoteEditor(noteId: String?) {
|
||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||
noteId?.let {
|
||||
@@ -653,25 +651,25 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
|
||||
private fun openSettings() {
|
||||
// v1.5.0: Use new Jetpack Compose Settings
|
||||
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, REQUEST_SETTINGS)
|
||||
}
|
||||
|
||||
|
||||
private fun triggerManualSync() {
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("manual")) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Create sync service
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||
@@ -679,23 +677,23 @@ class MainActivity : AppCompatActivity() {
|
||||
SyncStateManager.markCompleted(message)
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Server ist erreichbar → Sync durchführen
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
|
||||
// Show result
|
||||
if (result.isSuccess) {
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
@@ -703,19 +701,19 @@ class MainActivity : AppCompatActivity() {
|
||||
} else {
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_settings -> {
|
||||
@@ -729,10 +727,10 @@ class MainActivity : AppCompatActivity() {
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
@@ -741,50 +739,50 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
|
||||
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
||||
// Restore was successful, reload notes
|
||||
loadNotes()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
|
||||
*
|
||||
*
|
||||
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
|
||||
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
|
||||
*
|
||||
*
|
||||
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
|
||||
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
|
||||
*
|
||||
*
|
||||
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
|
||||
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
|
||||
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
|
||||
*/
|
||||
private fun migrateChecklistsForBackwardsCompat() {
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
|
||||
|
||||
// Nur einmal ausführen
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
|
||||
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
|
||||
// generiert und hochgeladen wird
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
|
||||
@@ -792,24 +790,24 @@ class MainActivity : AppCompatActivity() {
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
|
||||
// Migration als erledigt markieren
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
|
||||
when (requestCode) {
|
||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||
if (grantResults.isNotEmpty() &&
|
||||
if (grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
showToast(getString(R.string.toast_notifications_enabled))
|
||||
} else {
|
||||
@@ -818,39 +816,39 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
|
||||
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
|
||||
*/
|
||||
private fun logLocaleInfo() {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
|
||||
|
||||
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
|
||||
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
|
||||
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
|
||||
|
||||
|
||||
// System Locale
|
||||
val systemLocale = java.util.Locale.getDefault()
|
||||
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
|
||||
|
||||
|
||||
// Resources Locale
|
||||
val resourcesLocale = resources.configuration.locales[0]
|
||||
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
|
||||
|
||||
|
||||
// Context Locale (API 24+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val contextLocales = resources.configuration.locales
|
||||
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
|
||||
}
|
||||
|
||||
|
||||
// Test String Loading
|
||||
val testString = getString(R.string.toast_already_synced)
|
||||
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
|
||||
Logger.d(TAG, "║ Result: '$testString'")
|
||||
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
|
||||
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
|
||||
|
||||
|
||||
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package dev.dettmer.simplenotes.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import dev.dettmer.simplenotes.storage.AppDatabase
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.ui.main.MainViewModel
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appModule = module {
|
||||
single {
|
||||
Room.databaseBuilder(
|
||||
androidContext(),
|
||||
AppDatabase::class.java,
|
||||
"notes_database"
|
||||
).build()
|
||||
}
|
||||
|
||||
single { get<AppDatabase>().noteDao() }
|
||||
single { get<AppDatabase>().deletedNoteDao() }
|
||||
|
||||
// Provide SharedPreferences
|
||||
single {
|
||||
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
single { NotesStorage(androidContext(), get()) }
|
||||
|
||||
viewModel { MainViewModel(get(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.dettmer.simplenotes.storage
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
|
||||
import dev.dettmer.simplenotes.storage.dao.NoteDao
|
||||
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
|
||||
import dev.dettmer.simplenotes.storage.entity.NoteEntity
|
||||
|
||||
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun noteDao(): NoteDao
|
||||
abstract fun deletedNoteDao(): DeletedNoteDao
|
||||
}
|
||||
@@ -1,77 +1,59 @@
|
||||
package dev.dettmer.simplenotes.storage
|
||||
|
||||
import android.content.Context
|
||||
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
|
||||
import dev.dettmer.simplenotes.storage.entity.NoteEntity
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
|
||||
class NotesStorage(private val context: Context) {
|
||||
|
||||
class NotesStorage(
|
||||
private val context: Context,
|
||||
database: AppDatabase
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotesStorage"
|
||||
// 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen
|
||||
private val deletionTrackerMutex = Mutex()
|
||||
}
|
||||
|
||||
private val notesDir: File = File(context.filesDir, "notes").apply {
|
||||
if (!exists()) mkdirs()
|
||||
|
||||
private val noteDao = database.noteDao()
|
||||
private val deletedNoteDao = database.deletedNoteDao()
|
||||
|
||||
suspend fun saveNote(note: NoteEntity) {
|
||||
noteDao.saveNote(note)
|
||||
}
|
||||
|
||||
fun saveNote(note: Note) {
|
||||
val file = File(notesDir, "${note.id}.json")
|
||||
file.writeText(note.toJson())
|
||||
|
||||
suspend fun loadNote(id: String): NoteEntity? {
|
||||
return noteDao.getNote(id)
|
||||
}
|
||||
|
||||
fun loadNote(id: String): Note? {
|
||||
val file = File(notesDir, "$id.json")
|
||||
return if (file.exists()) {
|
||||
Note.fromJson(file.readText())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun loadAllNotes(): List<NoteEntity> {
|
||||
return noteDao.getAllNotes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Notizen aus dem lokalen Speicher.
|
||||
*
|
||||
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt,
|
||||
* damit der User die Sortierung konfigurieren kann.
|
||||
*/
|
||||
fun loadAllNotes(): List<Note> {
|
||||
return notesDir.listFiles()
|
||||
?.filter { it.extension == "json" }
|
||||
?.mapNotNull { Note.fromJson(it.readText()) }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
fun deleteNote(id: String): Boolean {
|
||||
val file = File(notesDir, "$id.json")
|
||||
val deleted = file.delete()
|
||||
|
||||
if (deleted) {
|
||||
|
||||
suspend fun deleteNote(id: String): Boolean {
|
||||
val deletedRows = noteDao.deleteNoteById(id)
|
||||
|
||||
if (deletedRows > 0) {
|
||||
Logger.d(TAG, "🗑️ Deleted note: $id")
|
||||
|
||||
// Track deletion to prevent zombie notes
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
trackDeletion(id, deviceId)
|
||||
trackDeletionSafe(id, deviceId)
|
||||
return true
|
||||
}
|
||||
|
||||
return deleted
|
||||
return false
|
||||
}
|
||||
|
||||
fun deleteAllNotes(): Boolean {
|
||||
|
||||
suspend fun deleteAllNotes(): Boolean {
|
||||
return try {
|
||||
val notes = loadAllNotes()
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
|
||||
for (note in notes) {
|
||||
deleteNote(note.id) // Uses trackDeletion() automatically
|
||||
|
||||
// Batch tracking and deleting
|
||||
notes.forEach { note ->
|
||||
trackDeletionSafe(note.id, deviceId)
|
||||
}
|
||||
|
||||
noteDao.deleteAllNotes()
|
||||
|
||||
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
@@ -79,104 +61,31 @@ class NotesStorage(private val context: Context) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// === Deletion Tracking ===
|
||||
|
||||
private fun getDeletionTrackerFile(): File {
|
||||
return File(context.filesDir, "deleted_notes.json")
|
||||
}
|
||||
|
||||
fun loadDeletionTracker(): DeletionTracker {
|
||||
val file = getDeletionTrackerFile()
|
||||
if (!file.exists()) {
|
||||
return DeletionTracker()
|
||||
}
|
||||
|
||||
return try {
|
||||
val json = file.readText()
|
||||
DeletionTracker.fromJson(json) ?: DeletionTracker()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to load deletion tracker", e)
|
||||
DeletionTracker()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveDeletionTracker(tracker: DeletionTracker) {
|
||||
try {
|
||||
val file = getDeletionTrackerFile()
|
||||
file.writeText(tracker.toJson())
|
||||
|
||||
if (tracker.deletedNotes.size > 1000) {
|
||||
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to save deletion tracker", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
|
||||
*
|
||||
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
|
||||
* auf den Deletion Tracker.
|
||||
*
|
||||
* @param noteId ID der gelöschten Notiz
|
||||
* @param deviceId Geräte-ID für Konflikt-Erkennung
|
||||
*/
|
||||
|
||||
suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
|
||||
deletionTrackerMutex.withLock {
|
||||
val tracker = loadDeletionTracker()
|
||||
tracker.addDeletion(noteId, deviceId)
|
||||
saveDeletionTracker(tracker)
|
||||
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy-Methode ohne Mutex-Schutz.
|
||||
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
|
||||
*
|
||||
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
|
||||
*/
|
||||
fun trackDeletion(noteId: String, deviceId: String) {
|
||||
val tracker = loadDeletionTracker()
|
||||
tracker.addDeletion(noteId, deviceId)
|
||||
saveDeletionTracker(tracker)
|
||||
// Room handles internal transactions and thread-safety natively.
|
||||
// The Mutex is no longer required.
|
||||
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
|
||||
Logger.d(TAG, "📝 Tracked deletion: $noteId")
|
||||
}
|
||||
|
||||
fun isNoteDeleted(noteId: String): Boolean {
|
||||
val tracker = loadDeletionTracker()
|
||||
return tracker.isDeleted(noteId)
|
||||
|
||||
suspend fun isNoteDeleted(noteId: String): Boolean {
|
||||
return deletedNoteDao.isNoteDeleted(noteId)
|
||||
}
|
||||
|
||||
fun clearDeletionTracker() {
|
||||
saveDeletionTracker(DeletionTracker())
|
||||
|
||||
suspend fun clearDeletionTracker() {
|
||||
deletedNoteDao.clearTracker()
|
||||
Logger.d(TAG, "🗑️ Deletion tracker cleared")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
|
||||
* This ensures notes are uploaded to the new server on next sync
|
||||
*/
|
||||
fun resetAllSyncStatusToPending(): Int {
|
||||
val notes = loadAllNotes()
|
||||
var updatedCount = 0
|
||||
|
||||
notes.forEach { note ->
|
||||
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
|
||||
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
|
||||
saveNote(updatedNote)
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun resetAllSyncStatusToPending(): Int {
|
||||
val updatedCount = noteDao.updateSyncStatus(
|
||||
oldStatus = SyncStatus.SYNCED,
|
||||
newStatus = SyncStatus.PENDING
|
||||
)
|
||||
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
|
||||
return updatedCount
|
||||
}
|
||||
|
||||
|
||||
fun getNotesDir(): File = notesDir
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.dettmer.simplenotes.storage.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
|
||||
@Entity(tableName = "notes")
|
||||
data class NoteEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val content: String,
|
||||
val timestamp: Long,
|
||||
val syncStatus: SyncStatus
|
||||
)
|
||||
@@ -44,11 +44,12 @@ import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
/**
|
||||
* Main Activity with Jetpack Compose UI
|
||||
* v1.5.0: Complete MainActivity Redesign with Compose
|
||||
*
|
||||
*
|
||||
* Replaces the old 805-line MainActivity.kt with a modern
|
||||
* Compose-based implementation featuring:
|
||||
* - Notes list with swipe-to-delete
|
||||
@@ -58,22 +59,22 @@ import kotlinx.coroutines.launch
|
||||
* - Design consistent with ComposeSettingsActivity
|
||||
*/
|
||||
class ComposeMainActivity : ComponentActivity() {
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ComposeMainActivity"
|
||||
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
||||
private const val REQUEST_SETTINGS = 1002
|
||||
}
|
||||
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
|
||||
|
||||
private val viewModel: MainViewModel by viewModel()
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
|
||||
// Phase 3: Track if coming from editor to scroll to top
|
||||
private var cameFromEditor = false
|
||||
|
||||
|
||||
/**
|
||||
* BroadcastReceiver for Background-Sync Completion (Periodic Sync)
|
||||
*/
|
||||
@@ -81,9 +82,9 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val success = intent?.getBooleanExtra("success", false) ?: false
|
||||
val count = intent?.getIntExtra("count", 0) ?: 0
|
||||
|
||||
|
||||
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
||||
|
||||
|
||||
// UI refresh
|
||||
if (success && count > 0) {
|
||||
viewModel.loadNotes()
|
||||
@@ -91,60 +92,60 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install Splash Screen (Android 12+)
|
||||
installSplashScreen()
|
||||
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
// Apply Dynamic Colors for Material You (Android 12+)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
|
||||
// Enable edge-to-edge display
|
||||
enableEdgeToEdge()
|
||||
|
||||
|
||||
// Initialize Logger and enable file logging if configured
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
|
||||
// Clear old sync notifications on app start
|
||||
NotificationHelper.clearSyncNotifications(this)
|
||||
|
||||
|
||||
// Request notification permission (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
|
||||
// v1.4.1: Migrate checklists for backwards compatibility
|
||||
migrateChecklistsForBackwardsCompat()
|
||||
|
||||
|
||||
// Setup Sync State Observer
|
||||
setupSyncStateObserver()
|
||||
|
||||
|
||||
setContent {
|
||||
SimpleNotesTheme {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
// Dialog state for delete confirmation
|
||||
var deleteDialogData by remember { mutableStateOf<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 +164,70 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenNote = { noteId -> openNoteEditor(noteId) },
|
||||
onOpenSettings = { openSettings() },
|
||||
onCreateNote = { noteType -> createNote(noteType) }
|
||||
)
|
||||
|
||||
|
||||
// v1.8.0: Post-Update Changelog (shows once after update)
|
||||
UpdateChangelogSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
|
||||
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
|
||||
|
||||
|
||||
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
|
||||
// This ensures UI reflects current offline mode when returning from Settings
|
||||
viewModel.refreshOfflineModeState()
|
||||
|
||||
|
||||
// 🎨 v1.7.0: Refresh display mode when returning from Settings
|
||||
viewModel.refreshDisplayMode()
|
||||
|
||||
|
||||
// Register BroadcastReceiver for Background-Sync
|
||||
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
syncCompletedReceiver,
|
||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
)
|
||||
|
||||
|
||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
||||
|
||||
|
||||
// Reload notes
|
||||
viewModel.loadNotes()
|
||||
|
||||
|
||||
// Phase 3: Scroll to top if coming from editor (new/edited note)
|
||||
if (cameFromEditor) {
|
||||
viewModel.scrollToTop()
|
||||
cameFromEditor = false
|
||||
Logger.d(TAG, "📜 Came from editor - scrolling to top")
|
||||
}
|
||||
|
||||
|
||||
// Trigger Auto-Sync on app resume
|
||||
viewModel.triggerAutoSync("onResume")
|
||||
}
|
||||
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
|
||||
// Unregister BroadcastReceiver
|
||||
@Suppress("DEPRECATION")
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
||||
}
|
||||
|
||||
|
||||
private fun setupSyncStateObserver() {
|
||||
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
|
||||
SyncStateManager.syncStatus.observe(this) { status ->
|
||||
viewModel.updateSyncState(status)
|
||||
}
|
||||
|
||||
|
||||
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
|
||||
lifecycleScope.launch {
|
||||
SyncStateManager.syncProgress.collect { progress ->
|
||||
@@ -250,14 +251,14 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun openNoteEditor(noteId: String?) {
|
||||
cameFromEditor = true
|
||||
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||
noteId?.let {
|
||||
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
|
||||
}
|
||||
|
||||
|
||||
// v1.5.0: Add slide animation
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
@@ -266,12 +267,12 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
)
|
||||
startActivity(intent, options.toBundle())
|
||||
}
|
||||
|
||||
|
||||
private fun createNote(noteType: NoteType) {
|
||||
cameFromEditor = true
|
||||
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||
|
||||
|
||||
// v1.5.0: Add slide animation
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
@@ -280,7 +281,7 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
)
|
||||
startActivity(intent, options.toBundle())
|
||||
}
|
||||
|
||||
|
||||
private fun openSettings() {
|
||||
val intent = Intent(this, ComposeSettingsActivity::class.java)
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
@@ -291,10 +292,10 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
|
||||
}
|
||||
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
@@ -303,29 +304,29 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.1: Migrates existing checklists for backwards compatibility.
|
||||
*/
|
||||
private fun migrateChecklistsForBackwardsCompat() {
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
|
||||
|
||||
// Only run once
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val storage = NotesStorage(this)
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = SyncStatus.PENDING
|
||||
@@ -333,24 +334,24 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
|
||||
// Mark migration as done
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
|
||||
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
||||
// Settings changed, reload notes
|
||||
viewModel.loadNotes()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
|
||||
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||
override fun onRequestPermissionsResult(
|
||||
@@ -359,15 +360,15 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
|
||||
when (requestCode) {
|
||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||
if (grantResults.isNotEmpty() &&
|
||||
if (grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this,
|
||||
getString(R.string.toast_notifications_disabled),
|
||||
Toast.makeText(this,
|
||||
getString(R.string.toast_notifications_disabled),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
@@ -389,8 +390,8 @@ private fun DeleteConfirmationDialog(
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
|
||||
text = {
|
||||
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,7 +2,9 @@ package dev.dettmer.simplenotes.ui.main
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SortDirection
|
||||
@@ -31,50 +33,50 @@ import kotlinx.coroutines.withContext
|
||||
/**
|
||||
* ViewModel for MainActivity Compose
|
||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||
*
|
||||
*
|
||||
* Manages notes list, sync state, and deletion with undo.
|
||||
*/
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
class MainViewModel(
|
||||
private val storage: NotesStorage,
|
||||
private val prefs: SharedPreferences
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainViewModel"
|
||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(application)
|
||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Notes State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
private val _notes = MutableStateFlow<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 +87,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_isOfflineMode.value = newValue
|
||||
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue")
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🎨 v1.7.0: Display Mode State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
private val _displayMode = MutableStateFlow(
|
||||
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||
)
|
||||
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
||||
|
||||
|
||||
/**
|
||||
* Refresh display mode from SharedPreferences
|
||||
* Called when returning from Settings screen
|
||||
@@ -104,25 +106,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_displayMode.value = newValue
|
||||
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value} → $newValue")
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🔀 v1.8.0: Sort State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
private val _sortOption = MutableStateFlow(
|
||||
SortOption.fromPrefsValue(
|
||||
prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION
|
||||
)
|
||||
)
|
||||
val sortOption: StateFlow<SortOption> = _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 +140,68 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Sync State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
|
||||
val syncProgress: StateFlow<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
|
||||
@@ -207,24 +209,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private suspend fun loadNotesAsync() {
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val pendingIds = _pendingDeletions.value
|
||||
val filteredNotes = allNotes.filter { it.id !in pendingIds }
|
||||
|
||||
val filteredNotes = allNotes.filter { it.id !in pendingIds }.map { Note(
|
||||
id = it.id,
|
||||
content = it.content
|
||||
) }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Phase 3: Detect if a new note was added at the top
|
||||
val newFirstNoteId = filteredNotes.firstOrNull()?.id
|
||||
if (newFirstNoteId != null &&
|
||||
previousFirstNoteId != null &&
|
||||
if (newFirstNoteId != null &&
|
||||
previousFirstNoteId != null &&
|
||||
newFirstNoteId != previousFirstNoteId) {
|
||||
// New note at top → trigger scroll
|
||||
_scrollToTop.value = true
|
||||
Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top")
|
||||
}
|
||||
previousFirstNoteId = newFirstNoteId
|
||||
|
||||
|
||||
_notes.value = filteredNotes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Public loadNotes - delegates to async version
|
||||
*/
|
||||
@@ -233,25 +238,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
loadNotesAsync()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset scroll-to-top flag after scroll completed
|
||||
*/
|
||||
fun resetScrollToTop() {
|
||||
_scrollToTop.value = false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Force scroll to top (e.g., after returning from editor)
|
||||
*/
|
||||
fun scrollToTop() {
|
||||
_scrollToTop.value = true
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Multi-Select Actions (v1.5.0)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
/**
|
||||
* Toggle selection of a note
|
||||
*/
|
||||
@@ -262,56 +267,56 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_selectedNotes.value + noteId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start selection mode with initial note
|
||||
*/
|
||||
fun startSelectionMode(noteId: String) {
|
||||
_selectedNotes.value = setOf(noteId)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Select all notes
|
||||
*/
|
||||
fun selectAllNotes() {
|
||||
_selectedNotes.value = _notes.value.map { it.id }.toSet()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear selection and exit selection mode
|
||||
*/
|
||||
fun clearSelection() {
|
||||
_selectedNotes.value = emptySet()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get count of selected notes
|
||||
*/
|
||||
fun getSelectedCount(): Int = _selectedNotes.value.size
|
||||
|
||||
|
||||
/**
|
||||
* Delete all selected notes
|
||||
*/
|
||||
fun deleteSelectedNotes(deleteFromServer: Boolean) {
|
||||
val selectedIds = _selectedNotes.value.toList()
|
||||
val selectedNotes = _notes.value.filter { it.id in selectedIds }
|
||||
|
||||
|
||||
if (selectedNotes.isEmpty()) return
|
||||
|
||||
|
||||
// Add to pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
|
||||
|
||||
|
||||
// Delete from storage
|
||||
selectedNotes.forEach { note ->
|
||||
storage.deleteNote(note.id)
|
||||
}
|
||||
|
||||
|
||||
// Clear selection
|
||||
clearSelection()
|
||||
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
|
||||
|
||||
// Show snackbar with undo
|
||||
val count = selectedNotes.size
|
||||
val message = if (deleteFromServer) {
|
||||
@@ -319,7 +324,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} else {
|
||||
getString(R.string.snackbar_notes_deleted_local, count)
|
||||
}
|
||||
|
||||
|
||||
viewModelScope.launch {
|
||||
_showSnackbar.emit(SnackbarData(
|
||||
message = message,
|
||||
@@ -328,7 +333,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
undoDeleteMultiple(selectedNotes)
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
@Suppress("MagicNumber") // Snackbar timing coordination
|
||||
// If delete from server, actually delete after a short delay
|
||||
// (to allow undo action before server deletion)
|
||||
@@ -347,19 +352,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Undo deletion of multiple notes
|
||||
*/
|
||||
private fun undoDeleteMultiple(notes: List<Note>) {
|
||||
// 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) {
|
||||
// Add to pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value + note.id
|
||||
|
||||
|
||||
// Delete from storage
|
||||
storage.deleteNote(note.id)
|
||||
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
|
||||
|
||||
// Show snackbar with undo
|
||||
val message = if (deleteFromServer) {
|
||||
getString(R.string.snackbar_note_deleted_server, note.title)
|
||||
} else {
|
||||
getString(R.string.snackbar_note_deleted_local, note.title)
|
||||
}
|
||||
|
||||
|
||||
viewModelScope.launch {
|
||||
_showSnackbar.emit(SnackbarData(
|
||||
message = message,
|
||||
@@ -428,7 +433,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
undoDelete(note)
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
@Suppress("MagicNumber") // Snackbar timing
|
||||
// If delete from server, actually delete after snackbar timeout
|
||||
if (deleteFromServer) {
|
||||
@@ -443,21 +448,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Undo note deletion
|
||||
*/
|
||||
fun undoDelete(note: Note) {
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - note.id
|
||||
|
||||
|
||||
// Restore to storage
|
||||
storage.saveNote(note)
|
||||
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Actually delete note from server after snackbar dismissed
|
||||
*/
|
||||
@@ -468,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
webdavService.deleteNoteFromServer(noteId)
|
||||
}
|
||||
|
||||
|
||||
if (success) {
|
||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
|
||||
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
|
||||
@@ -483,7 +488,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete multiple notes from server with aggregated toast
|
||||
* Shows single toast at the end instead of one per note
|
||||
@@ -493,7 +498,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val webdavService = WebDavSyncService(getApplication())
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
|
||||
noteIds.forEach { noteId ->
|
||||
try {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
@@ -507,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
|
||||
val message = when {
|
||||
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
|
||||
@@ -525,22 +530,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finalize deletion (remove from pending set)
|
||||
*/
|
||||
fun finalizeDeletion(noteId: String) {
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Sync Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
fun updateSyncState(status: SyncStateManager.SyncStatus) {
|
||||
_syncState.value = status.state
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||
@@ -559,14 +564,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach)
|
||||
// Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren)
|
||||
val prefs = getApplication<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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ composeBom = "2026.01.00"
|
||||
navigationCompose = "2.7.6"
|
||||
lifecycleRuntimeCompose = "2.7.0"
|
||||
activityCompose = "1.8.2"
|
||||
room = "2.6.1"
|
||||
ksp = "2.0.0-1.0.21"
|
||||
koin = "3.5.3"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -37,6 +40,14 @@ androidx-compose-material-icons = { group = "androidx.compose.material", name =
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
|
||||
# Room Database
|
||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
# Core Koin for Kotlin projects
|
||||
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
||||
# Koin for Jetpack Compose integration
|
||||
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
@@ -44,4 +55,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
|
||||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user