Compare commits
3 Commits
7ddad7e5e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e7dc867bc | |||
| f0ae34cdaa | |||
| d868c532b7 |
@@ -44,12 +44,6 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Legacy MainActivity (XML-based) - kept for reference -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SimpleNotes" />
|
||||
|
||||
<!-- Editor Activity (Legacy - XML-based) -->
|
||||
<activity
|
||||
android:name=".NoteEditorActivity"
|
||||
|
||||
@@ -1,854 +0,0 @@
|
||||
@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||
|
||||
package dev.dettmer.simplenotes
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import android.widget.TextView
|
||||
import android.widget.CheckBox
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.view.Gravity
|
||||
import android.widget.PopupMenu
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
|
||||
/**
|
||||
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
|
||||
* Ersetzt durch ComposeMainActivity
|
||||
*/
|
||||
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var recyclerViewNotes: RecyclerView
|
||||
private lateinit var emptyStateCard: MaterialCardView
|
||||
private lateinit var fabAddNote: FloatingActionButton
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
||||
|
||||
// 🔄 v1.3.1: Sync Status Banner
|
||||
private lateinit var syncStatusBanner: LinearLayout
|
||||
private lateinit var syncStatusText: TextView
|
||||
|
||||
private lateinit var adapter: NotesAdapter
|
||||
private val storage by lazy { NotesStorage(this) }
|
||||
|
||||
// Menu reference for sync button state
|
||||
private var optionsMenu: Menu? = null
|
||||
|
||||
// Track pending deletions to prevent flicker when notes reload
|
||||
private val pendingDeletions = mutableSetOf<String>()
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
||||
private const val REQUEST_SETTINGS = 1002
|
||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||
private const val SYNC_COMPLETED_DELAY_MS = 1500L
|
||||
private const val ERROR_DISPLAY_DELAY_MS = 3000L
|
||||
}
|
||||
|
||||
/**
|
||||
* BroadcastReceiver für Background-Sync Completion (Periodic Sync)
|
||||
*/
|
||||
private val syncCompletedReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val success = intent?.getBooleanExtra("success", false) ?: false
|
||||
val count = intent?.getIntExtra("count", 0) ?: 0
|
||||
|
||||
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
||||
|
||||
// UI refresh
|
||||
if (success && count > 0) {
|
||||
loadNotes()
|
||||
Logger.d(TAG, "🔄 Notes reloaded after background sync")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install Splash Screen (Android 12+)
|
||||
installSplashScreen()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Apply Dynamic Colors for Android 12+ (Material You)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
// Alte Sync-Notifications beim App-Start löschen
|
||||
NotificationHelper.clearSyncNotifications(this)
|
||||
|
||||
// Permission für Notifications (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
// 🌍 v1.7.2: Debug Locale für Fehlersuche
|
||||
logLocaleInfo()
|
||||
|
||||
findViews()
|
||||
setupToolbar()
|
||||
setupRecyclerView()
|
||||
setupFab()
|
||||
|
||||
// v1.4.1: Migrate checklists for backwards compatibility
|
||||
migrateChecklistsForBackwardsCompat()
|
||||
|
||||
loadNotes()
|
||||
|
||||
// 🔄 v1.3.1: Observe sync state for UI updates
|
||||
setupSyncStateObserver()
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
|
||||
*/
|
||||
private fun setupSyncStateObserver() {
|
||||
SyncStateManager.syncStatus.observe(this) { status ->
|
||||
when (status.state) {
|
||||
SyncStateManager.SyncState.SYNCING -> {
|
||||
// Disable sync controls
|
||||
setSyncControlsEnabled(false)
|
||||
// 🔄 v1.3.1: Show sync status banner (ersetzt SwipeRefresh-Animation)
|
||||
syncStatusText.text = getString(R.string.sync_status_syncing)
|
||||
syncStatusBanner.visibility = View.VISIBLE
|
||||
}
|
||||
SyncStateManager.SyncState.COMPLETED -> {
|
||||
// Re-enable sync controls
|
||||
setSyncControlsEnabled(true)
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
// Show completed briefly, then hide
|
||||
syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
|
||||
lifecycleScope.launch {
|
||||
kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
|
||||
syncStatusBanner.visibility = View.GONE
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
}
|
||||
SyncStateManager.SyncState.ERROR -> {
|
||||
// Re-enable sync controls
|
||||
setSyncControlsEnabled(true)
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
// Show error briefly, then hide
|
||||
syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
|
||||
lifecycleScope.launch {
|
||||
kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
|
||||
syncStatusBanner.visibility = View.GONE
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
}
|
||||
SyncStateManager.SyncState.IDLE -> {
|
||||
setSyncControlsEnabled(true)
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
syncStatusBanner.visibility = View.GONE
|
||||
}
|
||||
// v1.5.0: Silent-Sync - Banner nicht anzeigen, aber Sync-Controls deaktivieren
|
||||
SyncStateManager.SyncState.SYNCING_SILENT -> {
|
||||
setSyncControlsEnabled(false)
|
||||
// Kein Banner anzeigen bei Silent-Sync (z.B. onResume Auto-Sync)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
|
||||
*/
|
||||
private fun setSyncControlsEnabled(enabled: Boolean) {
|
||||
// Menu Sync-Button
|
||||
optionsMenu?.findItem(R.id.action_sync)?.isEnabled = enabled
|
||||
// SwipeRefresh
|
||||
swipeRefreshLayout.isEnabled = enabled
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers")
|
||||
|
||||
// Register BroadcastReceiver für Background-Sync
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
syncCompletedReceiver,
|
||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
)
|
||||
|
||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
||||
|
||||
// Reload notes (scroll to top wird in loadNotes() gemacht)
|
||||
loadNotes()
|
||||
|
||||
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
|
||||
triggerAutoSync("onResume")
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatischer Sync (onResume)
|
||||
* - Nutzt WiFi-gebundenen Socket (VPN Fix!)
|
||||
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
|
||||
*
|
||||
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
|
||||
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
||||
*/
|
||||
private fun triggerAutoSync(source: String = "unknown") {
|
||||
// Throttling: Max 1 Sync pro Minute
|
||||
if (!canTriggerAutoSync()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 🔄 v1.3.1: Check if sync already running
|
||||
// v1.5.0: silent=true - kein Banner bei Auto-Sync
|
||||
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||
|
||||
// Update last sync timestamp
|
||||
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Server ist erreichbar → Sync durchführen
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
// Feedback abhängig von Source
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
|
||||
// onResume: Nur Success-Toast
|
||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
||||
loadNotes()
|
||||
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
SyncStateManager.markCompleted()
|
||||
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Auto-Sync getriggert werden darf (Throttling)
|
||||
*/
|
||||
private fun canTriggerAutoSync(): Boolean {
|
||||
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeSinceLastSync = now - lastSyncTime
|
||||
|
||||
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
||||
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
// Unregister BroadcastReceiver
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
||||
}
|
||||
|
||||
private fun findViews() {
|
||||
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
|
||||
emptyStateCard = findViewById(R.id.emptyStateCard)
|
||||
fabAddNote = findViewById(R.id.fabAddNote)
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
|
||||
|
||||
// 🔄 v1.3.1: Sync Status Banner
|
||||
syncStatusBanner = findViewById(R.id.syncStatusBanner)
|
||||
syncStatusText = findViewById(R.id.syncStatusText)
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
setSupportActionBar(toolbar)
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
adapter = NotesAdapter { note ->
|
||||
openNoteEditor(note.id)
|
||||
}
|
||||
recyclerViewNotes.adapter = adapter
|
||||
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
||||
|
||||
// 🔥 v1.1.2: Setup Pull-to-Refresh
|
||||
setupPullToRefresh()
|
||||
|
||||
// Setup Swipe-to-Delete
|
||||
setupSwipeToDelete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
|
||||
*/
|
||||
private fun setupPullToRefresh() {
|
||||
swipeRefreshLayout.setOnRefreshListener {
|
||||
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
|
||||
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
return@setOnRefreshListener
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
|
||||
if (serverUrl.isNullOrEmpty()) {
|
||||
showToast("⚠️ Server noch nicht konfiguriert")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
||||
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if server is reachable
|
||||
if (!syncService.isServerReachable()) {
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Perform sync
|
||||
val result = syncService.syncNotes()
|
||||
|
||||
if (result.isSuccess) {
|
||||
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
|
||||
loadNotes()
|
||||
} else {
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set Material 3 color scheme
|
||||
swipeRefreshLayout.setColorSchemeResources(
|
||||
com.google.android.material.R.color.material_dynamic_primary50
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupSwipeToDelete() {
|
||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
0, // No drag
|
||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT // Swipe left or right
|
||||
) {
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean = false
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val position = viewHolder.bindingAdapterPosition
|
||||
val swipedNote = adapter.currentList[position]
|
||||
|
||||
// Store original list BEFORE removing note
|
||||
val originalList = adapter.currentList.toList()
|
||||
|
||||
// Remove from list for visual feedback (NOT from storage yet!)
|
||||
val listWithoutNote = originalList.toMutableList().apply {
|
||||
removeAt(position)
|
||||
}
|
||||
adapter.submitList(listWithoutNote)
|
||||
|
||||
// Show dialog with ability to restore
|
||||
showServerDeletionDialog(swipedNote, originalList)
|
||||
}
|
||||
|
||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
||||
// Require 80% swipe to trigger
|
||||
return 0.8f
|
||||
}
|
||||
})
|
||||
|
||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
||||
}
|
||||
|
||||
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
|
||||
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
||||
|
||||
if (alwaysDeleteFromServer) {
|
||||
// Auto-delete from server without asking
|
||||
deleteNoteLocally(note, deleteFromServer = true)
|
||||
return
|
||||
}
|
||||
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
|
||||
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
|
||||
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.legacy_delete_dialog_title))
|
||||
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
|
||||
.setView(dialogView)
|
||||
.setNeutralButton(getString(R.string.cancel)) { _, _ ->
|
||||
// RESTORE: Re-submit original list (note is NOT deleted from storage)
|
||||
adapter.submitList(originalList)
|
||||
}
|
||||
.setOnCancelListener {
|
||||
// User pressed back - also restore
|
||||
adapter.submitList(originalList)
|
||||
}
|
||||
.setPositiveButton("Nur lokal") { _, _ ->
|
||||
if (checkboxAlways.isChecked) {
|
||||
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
|
||||
}
|
||||
// NOW actually delete from storage
|
||||
deleteNoteLocally(note, deleteFromServer = false)
|
||||
}
|
||||
.setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
|
||||
if (checkboxAlways.isChecked) {
|
||||
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
|
||||
}
|
||||
deleteNoteLocally(note, deleteFromServer = true)
|
||||
}
|
||||
.setCancelable(true)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
|
||||
// Track pending deletion to prevent flicker
|
||||
pendingDeletions.add(note.id)
|
||||
|
||||
// Delete from storage
|
||||
storage.deleteNote(note.id)
|
||||
|
||||
// Reload to reflect changes
|
||||
loadNotes()
|
||||
|
||||
// Show Snackbar with UNDO option
|
||||
val message = if (deleteFromServer) {
|
||||
getString(R.string.legacy_delete_with_server, note.title)
|
||||
} else {
|
||||
getString(R.string.legacy_delete_local_only, note.title)
|
||||
}
|
||||
|
||||
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
|
||||
.setAction(getString(R.string.snackbar_undo)) {
|
||||
// UNDO: Restore note
|
||||
storage.saveNote(note)
|
||||
pendingDeletions.remove(note.id)
|
||||
loadNotes()
|
||||
}
|
||||
.addCallback(object : Snackbar.Callback() {
|
||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||
if (event != DISMISS_EVENT_ACTION) {
|
||||
// Snackbar dismissed without UNDO
|
||||
pendingDeletions.remove(note.id)
|
||||
|
||||
// Delete from server if requested
|
||||
if (deleteFromServer) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val webdavService = WebDavSyncService(this@MainActivity)
|
||||
val success = webdavService.deleteNoteFromServer(note.id)
|
||||
if (success) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
getString(R.string.snackbar_deleted_from_server),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
runOnUiThread {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
getString(R.string.snackbar_server_delete_failed),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"Server-Fehler: ${e.message}",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
|
||||
*/
|
||||
private fun setupFab() {
|
||||
fabAddNote.setOnClickListener { view ->
|
||||
showNoteTypePopup(view)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
|
||||
*/
|
||||
private fun showNoteTypePopup(anchor: View) {
|
||||
val popupMenu = PopupMenu(this, anchor, Gravity.END)
|
||||
popupMenu.inflate(R.menu.menu_fab_note_types)
|
||||
|
||||
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
|
||||
try {
|
||||
val fields = popupMenu.javaClass.declaredFields
|
||||
for (field in fields) {
|
||||
if ("mPopup" == field.name) {
|
||||
field.isAccessible = true
|
||||
val menuPopupHelper = field.get(popupMenu)
|
||||
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
|
||||
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
|
||||
setForceIcons.invoke(menuPopupHelper, true)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
|
||||
}
|
||||
|
||||
popupMenu.setOnMenuItemClickListener { menuItem ->
|
||||
val noteType = when (menuItem.itemId) {
|
||||
R.id.action_create_text_note -> NoteType.TEXT
|
||||
R.id.action_create_checklist -> NoteType.CHECKLIST
|
||||
else -> return@setOnMenuItemClickListener false
|
||||
}
|
||||
|
||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
popupMenu.show()
|
||||
}
|
||||
|
||||
private fun loadNotes() {
|
||||
val notes = storage.loadAllNotes()
|
||||
|
||||
// Filter out notes that are pending deletion (prevent flicker)
|
||||
val filteredNotes = notes.filter { it.id !in pendingDeletions }
|
||||
|
||||
// Submit list with callback to scroll to top after list is updated
|
||||
adapter.submitList(filteredNotes) {
|
||||
// Scroll to top after list update is complete
|
||||
// Wichtig: Nach dem Erstellen/Bearbeiten einer Notiz
|
||||
if (filteredNotes.isNotEmpty()) {
|
||||
recyclerViewNotes.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Material 3 Empty State Card
|
||||
emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
|
||||
android.view.View.VISIBLE
|
||||
} else {
|
||||
android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun openNoteEditor(noteId: String?) {
|
||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||
noteId?.let {
|
||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_ID, it)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun openSettings() {
|
||||
// v1.5.0: Use new Jetpack Compose Settings
|
||||
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, REQUEST_SETTINGS)
|
||||
}
|
||||
|
||||
private fun triggerManualSync() {
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("manual")) {
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Create sync service
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||
val message = getString(R.string.toast_already_synced)
|
||||
SyncStateManager.markCompleted(message)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Server ist erreichbar → Sync durchführen
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
// Show result
|
||||
if (result.isSuccess) {
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
loadNotes() // Reload notes
|
||||
} else {
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_settings -> {
|
||||
openSettings()
|
||||
true
|
||||
}
|
||||
R.id.action_sync -> {
|
||||
triggerManualSync()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
REQUEST_NOTIFICATION_PERMISSION
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
||||
// Restore was successful, reload notes
|
||||
loadNotes()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
|
||||
*
|
||||
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
|
||||
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
|
||||
*
|
||||
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
|
||||
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
|
||||
*
|
||||
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
|
||||
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
|
||||
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
|
||||
*/
|
||||
private fun migrateChecklistsForBackwardsCompat() {
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
|
||||
// Nur einmal ausführen
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
|
||||
// generiert und hochgeladen wird
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
|
||||
)
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
// Migration als erledigt markieren
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
when (requestCode) {
|
||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||
if (grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
showToast(getString(R.string.toast_notifications_enabled))
|
||||
} else {
|
||||
showToast(getString(R.string.toast_notifications_disabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
|
||||
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
|
||||
*/
|
||||
private fun logLocaleInfo() {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
|
||||
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
|
||||
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
|
||||
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
|
||||
|
||||
// System Locale
|
||||
val systemLocale = java.util.Locale.getDefault()
|
||||
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
|
||||
|
||||
// Resources Locale
|
||||
val resourcesLocale = resources.configuration.locales[0]
|
||||
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
|
||||
|
||||
// Context Locale (API 24+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val contextLocales = resources.configuration.locales
|
||||
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
|
||||
}
|
||||
|
||||
// Test String Loading
|
||||
val testString = getString(R.string.toast_already_synced)
|
||||
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
|
||||
Logger.d(TAG, "║ Result: '$testString'")
|
||||
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
|
||||
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
|
||||
|
||||
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -24,6 +25,9 @@ import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.java.KoinJavaComponent
|
||||
|
||||
/**
|
||||
* Editor Activity für Notizen und Checklisten
|
||||
@@ -42,8 +46,6 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
private lateinit var rvChecklistItems: RecyclerView
|
||||
private lateinit var btnAddItem: MaterialButton
|
||||
|
||||
private lateinit var storage: NotesStorage
|
||||
|
||||
// State
|
||||
private var existingNote: Note? = null
|
||||
private var currentNoteType: NoteType = NoteType.TEXT
|
||||
@@ -57,6 +59,8 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
||||
}
|
||||
|
||||
private val storage: NotesStorage by KoinJavaComponent.inject(NotesStorage::class.java)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -65,8 +69,6 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
setContentView(R.layout.activity_editor)
|
||||
|
||||
storage = NotesStorage(this)
|
||||
|
||||
findViews()
|
||||
setupToolbar()
|
||||
loadNoteOrDetermineType()
|
||||
@@ -93,6 +95,8 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
|
||||
|
||||
if (noteId != null) {
|
||||
|
||||
lifecycleScope.launch {
|
||||
// Existierende Notiz laden
|
||||
existingNote = storage.loadNote(noteId)
|
||||
existingNote?.let { note ->
|
||||
@@ -113,6 +117,7 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Neue Notiz - Typ aus Intent
|
||||
val typeString = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||
@@ -286,7 +291,7 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
storage.saveNote(note)
|
||||
lifecycleScope.launch { storage.saveNote(note) }
|
||||
}
|
||||
|
||||
NoteType.CHECKLIST -> {
|
||||
@@ -323,7 +328,7 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
storage.saveNote(note)
|
||||
lifecycleScope.launch { storage.saveNote(note) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +349,7 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
private fun deleteNote() {
|
||||
existingNote?.let {
|
||||
storage.deleteNote(it.id)
|
||||
lifecycleScope.launch { storage.deleteNote(it.id) }
|
||||
showToast(getString(R.string.note_deleted))
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.annotation.SuppressLint
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
@@ -32,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import dev.dettmer.simplenotes.backup.BackupManager
|
||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.UrlValidator
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
@@ -40,10 +42,12 @@ import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.getValue
|
||||
|
||||
@Suppress("LargeClass", "DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
@@ -107,9 +111,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||
uri?.let { showRestoreDialog(RestoreSource.LOCAL_FILE, it) }
|
||||
}
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
|
||||
}
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -700,8 +703,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||
// Initial-Export wenn Feature aktiviert wird
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(this@SettingsActivity)
|
||||
val currentNoteCount = noteStorage.loadAllNotes().size
|
||||
|
||||
val currentNoteCount = storage.loadAllNotes().size
|
||||
|
||||
if (currentNoteCount > 0) {
|
||||
// Zeige Progress-Dialog
|
||||
|
||||
@@ -11,9 +11,12 @@ import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.getValue
|
||||
|
||||
/**
|
||||
* BackupManager: Lokale Backup & Restore Funktionalität
|
||||
@@ -24,7 +27,7 @@ import java.util.*
|
||||
* - Auto-Backup vor Restore (Sicherheitsnetz)
|
||||
* - Backup-Validierung
|
||||
*/
|
||||
class BackupManager(private val context: Context) {
|
||||
class BackupManager(private val context: Context): KoinComponent {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BackupManager"
|
||||
@@ -34,7 +37,7 @@ class BackupManager(private val context: Context) {
|
||||
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(context)
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
|
||||
|
||||
@@ -275,7 +278,7 @@ class BackupManager(private val context: Context) {
|
||||
* Restore-Modus: MERGE
|
||||
* Fügt neue Notizen hinzu, behält bestehende
|
||||
*/
|
||||
private fun restoreMerge(backupNotes: List<Note>): RestoreResult {
|
||||
private suspend fun restoreMerge(backupNotes: List<Note>): RestoreResult {
|
||||
val existingNotes = storage.loadAllNotes()
|
||||
val existingIds = existingNotes.map { it.id }.toSet()
|
||||
|
||||
@@ -298,7 +301,7 @@ class BackupManager(private val context: Context) {
|
||||
* Restore-Modus: REPLACE
|
||||
* Löscht alle bestehenden Notizen, importiert Backup
|
||||
*/
|
||||
private fun restoreReplace(backupNotes: List<Note>): RestoreResult {
|
||||
private suspend fun restoreReplace(backupNotes: List<Note>): RestoreResult {
|
||||
// Alle bestehenden Notizen löschen
|
||||
storage.deleteAllNotes()
|
||||
|
||||
@@ -319,7 +322,7 @@ class BackupManager(private val context: Context) {
|
||||
* Restore-Modus: OVERWRITE_DUPLICATES
|
||||
* Backup überschreibt bei ID-Konflikten
|
||||
*/
|
||||
private fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
|
||||
private suspend fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
|
||||
val existingNotes = storage.loadAllNotes()
|
||||
val existingIds = existingNotes.map { it.id }.toSet()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import dev.dettmer.simplenotes.storage.AppDatabase
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.ui.main.MainViewModel
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
@@ -22,12 +23,14 @@ val appModule = module {
|
||||
single { get<AppDatabase>().noteDao() }
|
||||
single { get<AppDatabase>().deletedNoteDao() }
|
||||
|
||||
single { NotesStorage(androidContext(), get(), get()) }
|
||||
|
||||
// Provide SharedPreferences
|
||||
single {
|
||||
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
single { NotesStorage(androidContext(), get()) }
|
||||
|
||||
viewModel { MainViewModel(get(), get()) }
|
||||
|
||||
viewModel { MainViewModel(androidApplication()) }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package dev.dettmer.simplenotes.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import dev.dettmer.simplenotes.storage.converter.NoteConverters
|
||||
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
|
||||
import dev.dettmer.simplenotes.storage.dao.NoteDao
|
||||
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
|
||||
import dev.dettmer.simplenotes.storage.entity.NoteEntity
|
||||
|
||||
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
|
||||
@TypeConverters(NoteConverters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun noteDao(): NoteDao
|
||||
abstract fun deletedNoteDao(): DeletedNoteDao
|
||||
|
||||
@@ -1,59 +1,101 @@
|
||||
package dev.dettmer.simplenotes.storage
|
||||
|
||||
import android.content.Context
|
||||
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
|
||||
import dev.dettmer.simplenotes.storage.dao.NoteDao
|
||||
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
|
||||
import dev.dettmer.simplenotes.storage.entity.NoteEntity
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import java.io.File
|
||||
|
||||
class NotesStorage(
|
||||
private val context: Context,
|
||||
database: AppDatabase
|
||||
private val noteDao: NoteDao,
|
||||
private val deletedNoteDao: DeletedNoteDao,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotesStorage"
|
||||
}
|
||||
|
||||
private val noteDao = database.noteDao()
|
||||
private val deletedNoteDao = database.deletedNoteDao()
|
||||
|
||||
suspend fun saveNote(note: NoteEntity) {
|
||||
noteDao.saveNote(note)
|
||||
suspend fun saveNote(note: Note) {
|
||||
noteDao.saveNote(
|
||||
NoteEntity(
|
||||
id = note.id,
|
||||
title = note.title,
|
||||
content = note.content,
|
||||
createdAt = note.createdAt,
|
||||
updatedAt = note.updatedAt,
|
||||
deviceId = note.deviceId,
|
||||
syncStatus = note.syncStatus,
|
||||
noteType = note.noteType,
|
||||
checklistItems = note.checklistItems,
|
||||
checklistSortOption = note.checklistSortOption
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadNote(id: String): NoteEntity? {
|
||||
return noteDao.getNote(id)
|
||||
suspend fun loadNote(id: String): Note? {
|
||||
return noteDao.getNote(id)?.let { note ->
|
||||
Note(
|
||||
id = note.id,
|
||||
title = note.title,
|
||||
content = note.content,
|
||||
createdAt = note.createdAt,
|
||||
updatedAt = note.updatedAt,
|
||||
deviceId = note.deviceId,
|
||||
syncStatus = note.syncStatus,
|
||||
noteType = note.noteType,
|
||||
checklistItems = note.checklistItems,
|
||||
checklistSortOption = note.checklistSortOption
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadAllNotes(): List<NoteEntity> {
|
||||
return noteDao.getAllNotes()
|
||||
suspend fun loadAllNotes(): List<Note> {
|
||||
return noteDao.getAllNotes().map { note ->
|
||||
Note(
|
||||
id = note.id,
|
||||
title = note.title,
|
||||
content = note.content,
|
||||
createdAt = note.createdAt,
|
||||
updatedAt = note.updatedAt,
|
||||
deviceId = note.deviceId,
|
||||
syncStatus = note.syncStatus,
|
||||
noteType = note.noteType,
|
||||
checklistItems = note.checklistItems,
|
||||
checklistSortOption = note.checklistSortOption
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteNote(id: String): Boolean {
|
||||
val deletedRows = noteDao.deleteNoteById(id)
|
||||
val deleted = noteDao.deleteNoteById(id) > 0
|
||||
|
||||
if (deletedRows > 0) {
|
||||
Logger.d(TAG, "🗑️ Deleted note: $id")
|
||||
if (deleted) {
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
trackDeletionSafe(id, deviceId)
|
||||
return true
|
||||
deletedNoteDao.trackDeletion(DeletedNoteEntity(id, deviceId))
|
||||
}
|
||||
return false
|
||||
|
||||
return deleted
|
||||
}
|
||||
|
||||
suspend fun deleteAllNotes(): Boolean {
|
||||
return try {
|
||||
val notes = loadAllNotes()
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
val notes = noteDao.getAllNotes()
|
||||
|
||||
// Batch tracking and deleting
|
||||
notes.forEach { note ->
|
||||
trackDeletionSafe(note.id, deviceId)
|
||||
}
|
||||
noteDao.deleteAllNotes()
|
||||
|
||||
for (note in notes) {
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
deletedNoteDao.trackDeletion(DeletedNoteEntity(note.id, deviceId))
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
@@ -64,9 +106,60 @@ class NotesStorage(
|
||||
|
||||
// === 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) {
|
||||
// Room handles internal transactions and thread-safety natively.
|
||||
// The Mutex is no longer required.
|
||||
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy-Methode ohne Mutex-Schutz.
|
||||
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
|
||||
*
|
||||
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
|
||||
*/
|
||||
suspend fun trackDeletion(noteId: String, deviceId: String) {
|
||||
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
|
||||
Logger.d(TAG, "📝 Tracked deletion: $noteId")
|
||||
}
|
||||
@@ -77,14 +170,17 @@ class NotesStorage(
|
||||
|
||||
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
|
||||
*/
|
||||
suspend fun resetAllSyncStatusToPending(): Int {
|
||||
val updatedCount = noteDao.updateSyncStatus(
|
||||
oldStatus = SyncStatus.SYNCED,
|
||||
newStatus = SyncStatus.PENDING
|
||||
)
|
||||
var updatedCount = noteDao.updateSyncStatus(SyncStatus.SYNCED, SyncStatus.PENDING)
|
||||
|
||||
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
|
||||
return updatedCount
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.dettmer.simplenotes.storage.converter
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
|
||||
class NoteConverters {
|
||||
private val gson = Gson()
|
||||
|
||||
// --- NoteType Enum ---
|
||||
@TypeConverter
|
||||
fun fromNoteType(value: NoteType): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toNoteType(value: String): NoteType = NoteType.valueOf(value)
|
||||
|
||||
// --- SyncStatus Enum ---
|
||||
@TypeConverter
|
||||
fun fromSyncStatus(value: SyncStatus): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toSyncStatus(value: String): SyncStatus = SyncStatus.valueOf(value)
|
||||
|
||||
// --- ChecklistItem List ---
|
||||
@TypeConverter
|
||||
fun fromChecklistItems(items: List<ChecklistItem>?): String? {
|
||||
return items?.let { gson.toJson(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toChecklistItems(json: String?): List<ChecklistItem>? {
|
||||
if (json == null) return null
|
||||
val type = object : TypeToken<List<ChecklistItem>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,21 @@ package dev.dettmer.simplenotes.storage.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
|
||||
@Entity(tableName = "notes")
|
||||
data class NoteEntity(
|
||||
@PrimaryKey val id: String,
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val timestamp: Long,
|
||||
val syncStatus: SyncStatus
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val deviceId: String,
|
||||
val syncStatus: SyncStatus,
|
||||
val noteType: NoteType,
|
||||
val checklistItems: List<ChecklistItem>?, // Handled by TypeConverter
|
||||
val checklistSortOption: String?
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import com.thegrizzlylabs.sardineandroid.Sardine
|
||||
@@ -21,6 +22,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Socket
|
||||
@@ -50,8 +52,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
private val syncMutex = Mutex()
|
||||
}
|
||||
|
||||
private val storage: NotesStorage
|
||||
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
|
||||
private var markdownDirEnsured = false // Cache für Ordner-Existenz
|
||||
private var notesDirEnsured = false // ⚡ v1.3.1: Cache für /notes/ Ordner-Existenz
|
||||
|
||||
@@ -70,10 +72,9 @@ class WebDavSyncService(private val context: Context) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Creating NotesStorage...")
|
||||
}
|
||||
storage = NotesStorage(context)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " ✅ NotesStorage created successfully")
|
||||
Logger.d(TAG, " Notes dir: ${storage.getNotesDir()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in NotesStorage creation!", e)
|
||||
@@ -403,7 +404,6 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
// Check 2: Local changes
|
||||
val storage = NotesStorage(context)
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val hasLocalChanges = allNotes.any { note ->
|
||||
note.updatedAt > lastSyncTime
|
||||
@@ -823,7 +823,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
||||
// Sync logic requires nested conditions for comprehensive error handling and state management
|
||||
private fun uploadLocalNotes(
|
||||
private suspend fun uploadLocalNotes(
|
||||
sardine: Sardine,
|
||||
serverUrl: String,
|
||||
onProgress: (current: Int, total: Int, noteTitle: String) -> Unit = { _, _, _ -> } // 🆕 v1.8.0
|
||||
@@ -1128,7 +1128,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
* @param localNotes Alle lokalen Notizen
|
||||
* @return Anzahl der als DELETED_ON_SERVER markierten Notizen
|
||||
*/
|
||||
private fun detectServerDeletions(
|
||||
suspend private fun detectServerDeletions(
|
||||
serverNoteIds: Set<String>,
|
||||
localNotes: List<Note>
|
||||
): Int {
|
||||
@@ -1194,7 +1194,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
)
|
||||
// Sync logic requires nested conditions for comprehensive error handling and conflict resolution
|
||||
// TODO: Refactor into smaller functions in v1.9.0/v2.0.0 (see LINT_DETEKT_FEHLER_BEHEBUNG_PLAN.md)
|
||||
private fun downloadRemoteNotes(
|
||||
suspend private fun downloadRemoteNotes(
|
||||
sardine: Sardine,
|
||||
serverUrl: String,
|
||||
includeRootFallback: Boolean = false, // 🆕 v1.2.2: Only for restore from server
|
||||
@@ -1822,7 +1822,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
*/
|
||||
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
||||
// Import logic requires nested conditions for file validation and duplicate handling
|
||||
private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
|
||||
private suspend fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
|
||||
return try {
|
||||
Logger.d(TAG, "📝 Importing Markdown files...")
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -29,7 +30,9 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.util.UUID
|
||||
import kotlin.getValue
|
||||
|
||||
/**
|
||||
* ViewModel for NoteEditor Compose Screen
|
||||
@@ -48,8 +51,8 @@ class NoteEditorViewModel(
|
||||
const val ARG_NOTE_TYPE = "noteType"
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(application)
|
||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// State
|
||||
@@ -97,7 +100,7 @@ class NoteEditorViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadExistingNote(noteId: String) {
|
||||
private fun loadExistingNote(noteId: String) = viewModelScope.launch{
|
||||
existingNote = storage.loadNote(noteId)
|
||||
existingNote?.let { note ->
|
||||
currentNoteType = note.noteType
|
||||
@@ -564,10 +567,10 @@ class NoteEditorViewModel(
|
||||
* Nur checklistItems werden aktualisiert — nicht title oder content,
|
||||
* damit ungespeicherte Text-Änderungen im Editor nicht verloren gehen.
|
||||
*/
|
||||
fun reloadFromStorage() {
|
||||
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID) ?: return
|
||||
fun reloadFromStorage() = viewModelScope.launch{
|
||||
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID) ?: return@launch
|
||||
|
||||
val freshNote = storage.loadNote(noteId) ?: return
|
||||
val freshNote = storage.loadNote(noteId) ?: return@launch
|
||||
|
||||
// Nur Checklist-Items aktualisieren
|
||||
if (freshNote.noteType == NoteType.CHECKLIST) {
|
||||
@@ -578,7 +581,7 @@ class NoteEditorViewModel(
|
||||
isChecked = it.isChecked,
|
||||
order = it.order
|
||||
)
|
||||
} ?: return
|
||||
} ?: return@launch
|
||||
|
||||
_checklistItems.value = sortChecklistItems(freshItems)
|
||||
// existingNote aktualisieren damit beim Speichern der richtige
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -45,6 +46,8 @@ import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import kotlin.getValue
|
||||
|
||||
/**
|
||||
* Main Activity with Jetpack Compose UI
|
||||
@@ -68,9 +71,8 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: MainViewModel by viewModel()
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
|
||||
|
||||
// Phase 3: Track if coming from editor to scroll to top
|
||||
private var cameFromEditor = false
|
||||
@@ -309,37 +311,8 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
* v1.4.1: Migrates existing checklists for backwards compatibility.
|
||||
*/
|
||||
private fun migrateChecklistsForBackwardsCompat() {
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
viewModel.migrateChecklistsForBackwardsCompat()
|
||||
|
||||
// Only run once
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val storage = NotesStorage(this)
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
// Mark migration as done
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
|
||||
@@ -4,12 +4,13 @@ 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
|
||||
import dev.dettmer.simplenotes.models.SortOption
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncProgress
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
@@ -29,6 +30,8 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import kotlin.getValue
|
||||
|
||||
/**
|
||||
* ViewModel for MainActivity Compose
|
||||
@@ -36,10 +39,7 @@ import kotlinx.coroutines.withContext
|
||||
*
|
||||
* Manages notes list, sync state, and deletion with undo.
|
||||
*/
|
||||
class MainViewModel(
|
||||
private val storage: NotesStorage,
|
||||
private val prefs: SharedPreferences
|
||||
) : ViewModel() {
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainViewModel"
|
||||
@@ -47,6 +47,9 @@ class MainViewModel(
|
||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||
}
|
||||
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Notes State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -209,10 +212,7 @@ class MainViewModel(
|
||||
private suspend fun loadNotesAsync() {
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val pendingIds = _pendingDeletions.value
|
||||
val filteredNotes = allNotes.filter { it.id !in pendingIds }.map { Note(
|
||||
id = it.id,
|
||||
content = it.content
|
||||
) }
|
||||
val filteredNotes = allNotes.filter { it.id !in pendingIds }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Phase 3: Detect if a new note was added at the top
|
||||
@@ -297,11 +297,11 @@ class MainViewModel(
|
||||
/**
|
||||
* Delete all selected notes
|
||||
*/
|
||||
fun deleteSelectedNotes(deleteFromServer: Boolean) {
|
||||
fun deleteSelectedNotes(deleteFromServer: Boolean) = viewModelScope.launch {
|
||||
val selectedIds = _selectedNotes.value.toList()
|
||||
val selectedNotes = _notes.value.filter { it.id in selectedIds }
|
||||
|
||||
if (selectedNotes.isEmpty()) return
|
||||
if (selectedNotes.isEmpty()) return@launch
|
||||
|
||||
// Add to pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
|
||||
@@ -356,7 +356,7 @@ class MainViewModel(
|
||||
/**
|
||||
* Undo deletion of multiple notes
|
||||
*/
|
||||
private fun undoDeleteMultiple(notes: List<Note>) {
|
||||
private fun undoDeleteMultiple(notes: List<Note>) = viewModelScope.launch{
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
|
||||
|
||||
@@ -408,7 +408,7 @@ class MainViewModel(
|
||||
/**
|
||||
* Confirm note deletion (from dialog or auto-delete)
|
||||
*/
|
||||
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
|
||||
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) = viewModelScope.launch{
|
||||
// Add to pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value + note.id
|
||||
|
||||
@@ -452,7 +452,7 @@ class MainViewModel(
|
||||
/**
|
||||
* Undo note deletion
|
||||
*/
|
||||
fun undoDelete(note: Note) {
|
||||
fun undoDelete(note: Note) = viewModelScope.launch{
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - note.id
|
||||
|
||||
@@ -832,4 +832,37 @@ class MainViewModel(
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||
}
|
||||
|
||||
fun migrateChecklistsForBackwardsCompat() = viewModelScope.launch{
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
|
||||
// Only run once
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
// Mark migration as done
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
@@ -26,8 +27,10 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.getValue
|
||||
|
||||
/**
|
||||
* ViewModel for Settings screens
|
||||
@@ -45,9 +48,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important)
|
||||
}
|
||||
|
||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val backupManager = BackupManager(application)
|
||||
private val notesStorage = NotesStorage(application) // v1.7.0: For server change detection
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
|
||||
|
||||
// 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection
|
||||
// This prevents false-positive "server changed" toasts during text input
|
||||
@@ -308,7 +311,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
// Reset sync status if server actually changed
|
||||
if (serverChanged) {
|
||||
viewModelScope.launch {
|
||||
val count = notesStorage.resetAllSyncStatusToPending()
|
||||
val count = storage.resetAllSyncStatusToPending()
|
||||
Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
|
||||
emitToast(getString(R.string.toast_server_changed_sync_reset, count))
|
||||
}
|
||||
@@ -589,8 +592,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
|
||||
// Check if there are notes to export
|
||||
val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(getApplication())
|
||||
val noteCount = noteStorage.loadAllNotes().size
|
||||
val noteCount = storage.loadAllNotes().size
|
||||
|
||||
if (noteCount > 0) {
|
||||
// Show progress and perform initial export
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.dettmer.simplenotes.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
@@ -12,6 +13,9 @@ import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import kotlin.getValue
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.0: Homescreen Widget für Notizen und Checklisten
|
||||
@@ -52,10 +56,11 @@ class NoteWidget : GlanceAppWidget() {
|
||||
)
|
||||
)
|
||||
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val storage = NotesStorage(context)
|
||||
|
||||
provideContent {
|
||||
val prefs = currentState<Preferences>()
|
||||
@@ -65,7 +70,7 @@ class NoteWidget : GlanceAppWidget() {
|
||||
val bgOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f
|
||||
|
||||
val note = noteId?.let { nId ->
|
||||
storage.loadNote(nId)
|
||||
runBlocking { storage.loadNote(nId) }
|
||||
}
|
||||
|
||||
GlanceTheme {
|
||||
|
||||
@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.models.ChecklistSortOption
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.0: ActionParameter Keys für Widget-Interaktionen
|
||||
@@ -35,6 +36,9 @@ class ToggleChecklistItemAction : ActionCallback {
|
||||
private const val TAG = "ToggleChecklistItem"
|
||||
}
|
||||
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
|
||||
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
@@ -43,7 +47,6 @@ class ToggleChecklistItemAction : ActionCallback {
|
||||
val noteId = parameters[NoteWidgetActionKeys.KEY_NOTE_ID] ?: return
|
||||
val itemId = parameters[NoteWidgetActionKeys.KEY_ITEM_ID] ?: return
|
||||
|
||||
val storage = NotesStorage(context)
|
||||
val note = storage.loadNote(noteId) ?: return
|
||||
|
||||
val updatedItems = note.checklistItems?.map { item ->
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.lifecycle.lifecycleScope
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import kotlin.getValue
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.0: Konfigurations-Activity beim Platzieren eines Widgets
|
||||
@@ -40,6 +42,8 @@ class NoteWidgetConfigActivity : ComponentActivity() {
|
||||
private var currentLockState: Boolean = false
|
||||
private var currentOpacity: Float = 1.0f
|
||||
|
||||
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -69,13 +73,12 @@ class NoteWidgetConfigActivity : ComponentActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
val storage = NotesStorage(this)
|
||||
|
||||
// Bestehende Konfiguration laden (für Reconfigure)
|
||||
lifecycleScope.launch {
|
||||
var existingNoteId: String? = null
|
||||
var existingLock = false
|
||||
var existingOpacity = 1.0f
|
||||
val notes = storage.loadAllNotes()
|
||||
|
||||
try {
|
||||
val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity)
|
||||
@@ -100,7 +103,7 @@ class NoteWidgetConfigActivity : ComponentActivity() {
|
||||
setContent {
|
||||
SimpleNotesTheme {
|
||||
NoteWidgetConfigScreen(
|
||||
storage = storage,
|
||||
notes = notes,
|
||||
initialLock = existingLock,
|
||||
initialOpacity = existingOpacity,
|
||||
selectedNoteId = existingNoteId,
|
||||
|
||||
@@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -59,16 +58,16 @@ private const val NOTE_PREVIEW_MAX_LENGTH = 50
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NoteWidgetConfigScreen(
|
||||
storage: NotesStorage,
|
||||
notes: List<Note>,
|
||||
initialLock: Boolean = false,
|
||||
initialOpacity: Float = 1.0f,
|
||||
selectedNoteId: String? = null,
|
||||
onNoteSelected: (noteId: String, isLocked: Boolean, opacity: Float) -> Unit,
|
||||
onSave: ((noteId: String, isLocked: Boolean, opacity: Float) -> Unit)? = null,
|
||||
onSettingsChanged: ((noteId: String?, isLocked: Boolean, opacity: Float) -> Unit)? = null,
|
||||
onNoteSelected: (String, Boolean, Float) -> Unit,
|
||||
onSave: ((String, Boolean, Float) -> Unit)? = null,
|
||||
onSettingsChanged: ((String?, Boolean, Float) -> Unit)? = null,
|
||||
@Suppress("UNUSED_PARAMETER") onCancel: () -> Unit // Reserved for future use
|
||||
) {
|
||||
val allNotes = remember { storage.loadAllNotes().sortedByDescending { it.updatedAt } }
|
||||
val allNotes = remember { notes.sortedByDescending { it.updatedAt } }
|
||||
var lockWidget by remember { mutableStateOf(initialLock) }
|
||||
var opacity by remember { mutableFloatStateOf(initialOpacity) }
|
||||
var currentSelectedId by remember { mutableStateOf(selectedNoteId) }
|
||||
|
||||
@@ -17,7 +17,7 @@ navigationCompose = "2.7.6"
|
||||
lifecycleRuntimeCompose = "2.7.0"
|
||||
activityCompose = "1.8.2"
|
||||
room = "2.6.1"
|
||||
ksp = "2.0.0-1.0.21"
|
||||
ksp = "2.0.21-1.0.27"
|
||||
koin = "3.5.3"
|
||||
|
||||
[libraries]
|
||||
|
||||
Reference in New Issue
Block a user