Compare commits
3 Commits
v1.8.1
...
f0ae34cdaa
| Author | SHA1 | Date | |
|---|---|---|---|
| f0ae34cdaa | |||
| d868c532b7 | |||
| 7ddad7e5e7 |
@@ -4,6 +4,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
|
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
|
||||||
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
|
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
|
||||||
alias(libs.plugins.detekt)
|
alias(libs.plugins.detekt)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
@@ -25,13 +26,13 @@ android {
|
|||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
|
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
|
||||||
dependenciesInfo {
|
dependenciesInfo {
|
||||||
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
|
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
|
||||||
includeInBundle = false // Also disable for AAB (Google Play)
|
includeInBundle = false // Also disable for AAB (Google Play)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Product Flavors for F-Droid and standard builds
|
// Product Flavors for F-Droid and standard builds
|
||||||
// Note: APK splits are disabled to ensure single APK output
|
// Note: APK splits are disabled to ensure single APK output
|
||||||
flavorDimensions += "distribution"
|
flavorDimensions += "distribution"
|
||||||
@@ -42,7 +43,7 @@ android {
|
|||||||
// All dependencies in this project are already FOSS-compatible
|
// All dependencies in this project are already FOSS-compatible
|
||||||
// No APK splits - F-Droid expects single universal APK
|
// No APK splits - F-Droid expects single universal APK
|
||||||
}
|
}
|
||||||
|
|
||||||
create("standard") {
|
create("standard") {
|
||||||
dimension = "distribution"
|
dimension = "distribution"
|
||||||
// Standard builds can include Play Services in the future if needed
|
// Standard builds can include Play Services in the future if needed
|
||||||
@@ -57,7 +58,7 @@ android {
|
|||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
val keystoreProperties = Properties()
|
val keystoreProperties = Properties()
|
||||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
|
||||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
storePassword = keystoreProperties.getProperty("storePassword")
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
@@ -72,11 +73,11 @@ android {
|
|||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
versionNameSuffix = "-debug"
|
versionNameSuffix = "-debug"
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
|
|
||||||
// Optionales separates Icon-Label für Debug-Builds
|
// Optionales separates Icon-Label für Debug-Builds
|
||||||
resValue("string", "app_name_debug", "Simple Notes (Debug)")
|
resValue("string", "app_name_debug", "Simple Notes (Debug)")
|
||||||
}
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
@@ -98,12 +99,12 @@ android {
|
|||||||
buildConfig = true // Enable BuildConfig generation
|
buildConfig = true // Enable BuildConfig generation
|
||||||
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
|
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
||||||
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
|
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
|
||||||
// composeCompiler { }
|
// composeCompiler { }
|
||||||
@@ -162,6 +163,15 @@ dependencies {
|
|||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
|
// Koin
|
||||||
|
implementation(libs.koin.android)
|
||||||
|
implementation(libs.koin.androidx.compose)
|
||||||
|
|
||||||
|
// Room
|
||||||
|
implementation(libs.room.runtime)
|
||||||
|
implementation(libs.room.ktx)
|
||||||
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// 🆕 v1.8.0: Homescreen Widgets
|
// 🆕 v1.8.0: Homescreen Widgets
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -180,7 +190,7 @@ ktlint {
|
|||||||
outputToConsole = true
|
outputToConsole = true
|
||||||
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
|
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
|
||||||
enableExperimentalRules = false
|
enableExperimentalRules = false
|
||||||
|
|
||||||
filter {
|
filter {
|
||||||
exclude("**/generated/**")
|
exclude("**/generated/**")
|
||||||
exclude("**/build/**")
|
exclude("**/build/**")
|
||||||
@@ -196,7 +206,7 @@ detekt {
|
|||||||
allRules = false
|
allRules = false
|
||||||
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
|
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
|
||||||
baseline = file("$rootDir/config/detekt/baseline.xml")
|
baseline = file("$rootDir/config/detekt/baseline.xml")
|
||||||
|
|
||||||
// Parallel-Verarbeitung für schnellere Checks
|
// Parallel-Verarbeitung für schnellere Checks
|
||||||
parallel = true
|
parallel = true
|
||||||
}
|
}
|
||||||
@@ -205,13 +215,13 @@ detekt {
|
|||||||
// Single source of truth: F-Droid changelogs are reused in the app
|
// Single source of truth: F-Droid changelogs are reused in the app
|
||||||
tasks.register<Copy>("copyChangelogsToAssets") {
|
tasks.register<Copy>("copyChangelogsToAssets") {
|
||||||
description = "Copies F-Droid changelogs to app assets for post-update dialog"
|
description = "Copies F-Droid changelogs to app assets for post-update dialog"
|
||||||
|
|
||||||
from("$rootDir/../fastlane/metadata/android") {
|
from("$rootDir/../fastlane/metadata/android") {
|
||||||
include("*/changelogs/*.txt")
|
include("*/changelogs/*.txt")
|
||||||
}
|
}
|
||||||
|
|
||||||
into("$projectDir/src/main/assets/changelogs")
|
into("$projectDir/src/main/assets/changelogs")
|
||||||
|
|
||||||
// Preserve directory structure: en-US/20.txt, de-DE/20.txt
|
// Preserve directory structure: en-US/20.txt, de-DE/20.txt
|
||||||
eachFile {
|
eachFile {
|
||||||
val parts = relativePath.segments
|
val parts = relativePath.segments
|
||||||
@@ -222,11 +232,11 @@ tasks.register<Copy>("copyChangelogsToAssets") {
|
|||||||
relativePath = RelativePath(true, parts[0], parts[2])
|
relativePath = RelativePath(true, parts[0], parts[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
includeEmptyDirs = false
|
includeEmptyDirs = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run before preBuild to ensure changelogs are available
|
// Run before preBuild to ensure changelogs are available
|
||||||
tasks.named("preBuild") {
|
tasks.named("preBuild") {
|
||||||
dependsOn("copyChangelogsToAssets")
|
dependsOn("copyChangelogsToAssets")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,18 @@
|
|||||||
<!-- Network & Sync Permissions -->
|
<!-- Network & Sync Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- Battery Optimization (for WorkManager background sync) -->
|
<!-- Battery Optimization (for WorkManager background sync) -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
<!-- v1.7.1: Foreground Service for Expedited Work (Android 9-11) -->
|
<!-- v1.7.1: Foreground Service for Expedited Work (Android 9-11) -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<!-- v1.7.1: Foreground Service Type for Android 10+ -->
|
<!-- v1.7.1: Foreground Service Type for Android 10+ -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
|
<!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
@@ -44,12 +44,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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) -->
|
<!-- Editor Activity (Legacy - XML-based) -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".NoteEditorActivity"
|
android:name=".NoteEditorActivity"
|
||||||
@@ -125,4 +119,4 @@
|
|||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,856 +0,0 @@
|
|||||||
@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
|
||||||
|
|
||||||
package dev.dettmer.simplenotes
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
|
||||||
import com.google.android.material.color.DynamicColors
|
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import com.google.android.material.card.MaterialCardView
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
|
||||||
import dev.dettmer.simplenotes.models.Note
|
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
|
||||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
|
||||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
|
||||||
import dev.dettmer.simplenotes.utils.showToast
|
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.CheckBox
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
|
||||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.view.Gravity
|
|
||||||
import android.widget.PopupMenu
|
|
||||||
import dev.dettmer.simplenotes.models.NoteType
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
|
|
||||||
* Ersetzt durch ComposeMainActivity
|
|
||||||
*/
|
|
||||||
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
|
||||||
class MainActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
private lateinit var recyclerViewNotes: RecyclerView
|
|
||||||
private lateinit var emptyStateCard: MaterialCardView
|
|
||||||
private lateinit var fabAddNote: FloatingActionButton
|
|
||||||
private lateinit var toolbar: MaterialToolbar
|
|
||||||
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
|
||||||
|
|
||||||
// 🔄 v1.3.1: Sync Status Banner
|
|
||||||
private lateinit var syncStatusBanner: LinearLayout
|
|
||||||
private lateinit var syncStatusText: TextView
|
|
||||||
|
|
||||||
private lateinit var adapter: NotesAdapter
|
|
||||||
private val storage by lazy { NotesStorage(this) }
|
|
||||||
|
|
||||||
// Menu reference for sync button state
|
|
||||||
private var optionsMenu: Menu? = null
|
|
||||||
|
|
||||||
// Track pending deletions to prevent flicker when notes reload
|
|
||||||
private val pendingDeletions = mutableSetOf<String>()
|
|
||||||
|
|
||||||
private val prefs by lazy {
|
|
||||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "MainActivity"
|
|
||||||
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
|
||||||
private const val REQUEST_SETTINGS = 1002
|
|
||||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
|
||||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
|
||||||
private const val SYNC_COMPLETED_DELAY_MS = 1500L
|
|
||||||
private const val ERROR_DISPLAY_DELAY_MS = 3000L
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BroadcastReceiver für Background-Sync Completion (Periodic Sync)
|
|
||||||
*/
|
|
||||||
private val syncCompletedReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
val success = intent?.getBooleanExtra("success", false) ?: false
|
|
||||||
val count = intent?.getIntExtra("count", 0) ?: 0
|
|
||||||
|
|
||||||
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
|
||||||
|
|
||||||
// UI refresh
|
|
||||||
if (success && count > 0) {
|
|
||||||
loadNotes()
|
|
||||||
Logger.d(TAG, "🔄 Notes reloaded after background sync")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
// Install Splash Screen (Android 12+)
|
|
||||||
installSplashScreen()
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
// Apply Dynamic Colors for Android 12+ (Material You)
|
|
||||||
DynamicColors.applyToActivityIfAvailable(this)
|
|
||||||
|
|
||||||
setContentView(R.layout.activity_main)
|
|
||||||
|
|
||||||
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
|
|
||||||
Logger.init(this)
|
|
||||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
|
||||||
Logger.setFileLoggingEnabled(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alte Sync-Notifications beim App-Start löschen
|
|
||||||
NotificationHelper.clearSyncNotifications(this)
|
|
||||||
|
|
||||||
// Permission für Notifications (Android 13+)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
requestNotificationPermission()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🌍 v1.7.2: Debug Locale für Fehlersuche
|
|
||||||
logLocaleInfo()
|
|
||||||
|
|
||||||
findViews()
|
|
||||||
setupToolbar()
|
|
||||||
setupRecyclerView()
|
|
||||||
setupFab()
|
|
||||||
|
|
||||||
// v1.4.1: Migrate checklists for backwards compatibility
|
|
||||||
migrateChecklistsForBackwardsCompat()
|
|
||||||
|
|
||||||
loadNotes()
|
|
||||||
|
|
||||||
// 🔄 v1.3.1: Observe sync state for UI updates
|
|
||||||
setupSyncStateObserver()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
|
|
||||||
*/
|
|
||||||
private fun setupSyncStateObserver() {
|
|
||||||
SyncStateManager.syncStatus.observe(this) { status ->
|
|
||||||
when (status.state) {
|
|
||||||
SyncStateManager.SyncState.SYNCING -> {
|
|
||||||
// Disable sync controls
|
|
||||||
setSyncControlsEnabled(false)
|
|
||||||
// 🔄 v1.3.1: Show sync status banner (ersetzt SwipeRefresh-Animation)
|
|
||||||
syncStatusText.text = getString(R.string.sync_status_syncing)
|
|
||||||
syncStatusBanner.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
SyncStateManager.SyncState.COMPLETED -> {
|
|
||||||
// Re-enable sync controls
|
|
||||||
setSyncControlsEnabled(true)
|
|
||||||
swipeRefreshLayout.isRefreshing = false
|
|
||||||
// Show completed briefly, then hide
|
|
||||||
syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
|
|
||||||
lifecycleScope.launch {
|
|
||||||
kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
|
|
||||||
syncStatusBanner.visibility = View.GONE
|
|
||||||
SyncStateManager.reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SyncStateManager.SyncState.ERROR -> {
|
|
||||||
// Re-enable sync controls
|
|
||||||
setSyncControlsEnabled(true)
|
|
||||||
swipeRefreshLayout.isRefreshing = false
|
|
||||||
// Show error briefly, then hide
|
|
||||||
syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
|
|
||||||
lifecycleScope.launch {
|
|
||||||
kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
|
|
||||||
syncStatusBanner.visibility = View.GONE
|
|
||||||
SyncStateManager.reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SyncStateManager.SyncState.IDLE -> {
|
|
||||||
setSyncControlsEnabled(true)
|
|
||||||
swipeRefreshLayout.isRefreshing = false
|
|
||||||
syncStatusBanner.visibility = View.GONE
|
|
||||||
}
|
|
||||||
// v1.5.0: Silent-Sync - Banner nicht anzeigen, aber Sync-Controls deaktivieren
|
|
||||||
SyncStateManager.SyncState.SYNCING_SILENT -> {
|
|
||||||
setSyncControlsEnabled(false)
|
|
||||||
// Kein Banner anzeigen bei Silent-Sync (z.B. onResume Auto-Sync)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
|
|
||||||
*/
|
|
||||||
private fun setSyncControlsEnabled(enabled: Boolean) {
|
|
||||||
// Menu Sync-Button
|
|
||||||
optionsMenu?.findItem(R.id.action_sync)?.isEnabled = enabled
|
|
||||||
// SwipeRefresh
|
|
||||||
swipeRefreshLayout.isEnabled = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
|
|
||||||
Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers")
|
|
||||||
|
|
||||||
// Register BroadcastReceiver für Background-Sync
|
|
||||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
|
||||||
syncCompletedReceiver,
|
|
||||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
|
||||||
)
|
|
||||||
|
|
||||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
|
||||||
|
|
||||||
// Reload notes (scroll to top wird in loadNotes() gemacht)
|
|
||||||
loadNotes()
|
|
||||||
|
|
||||||
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
|
|
||||||
triggerAutoSync("onResume")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatischer Sync (onResume)
|
|
||||||
* - Nutzt WiFi-gebundenen Socket (VPN Fix!)
|
|
||||||
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
|
|
||||||
*
|
|
||||||
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
|
|
||||||
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
|
||||||
*/
|
|
||||||
private fun triggerAutoSync(source: String = "unknown") {
|
|
||||||
// Throttling: Max 1 Sync pro Minute
|
|
||||||
if (!canTriggerAutoSync()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔄 v1.3.1: Check if sync already running
|
|
||||||
// v1.5.0: silent=true - kein Banner bei Auto-Sync
|
|
||||||
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
|
||||||
|
|
||||||
// Update last sync timestamp
|
|
||||||
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
try {
|
|
||||||
val syncService = WebDavSyncService(this@MainActivity)
|
|
||||||
|
|
||||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
|
||||||
SyncStateManager.reset()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
|
||||||
syncService.isServerReachable()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isReachable) {
|
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
|
||||||
SyncStateManager.reset()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server ist erreichbar → Sync durchführen
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
|
||||||
syncService.syncNotes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feedback abhängig von Source
|
|
||||||
if (result.isSuccess && result.syncedCount > 0) {
|
|
||||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
|
||||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
|
||||||
|
|
||||||
// onResume: Nur Success-Toast
|
|
||||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
|
||||||
loadNotes()
|
|
||||||
|
|
||||||
} else if (result.isSuccess) {
|
|
||||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
|
||||||
SyncStateManager.markCompleted()
|
|
||||||
|
|
||||||
} else {
|
|
||||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
|
||||||
SyncStateManager.markError(result.errorMessage)
|
|
||||||
// Kein Toast - App ist im Hintergrund
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
|
||||||
SyncStateManager.markError(e.message)
|
|
||||||
// Kein Toast - App ist im Hintergrund
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prüft ob Auto-Sync getriggert werden darf (Throttling)
|
|
||||||
*/
|
|
||||||
private fun canTriggerAutoSync(): Boolean {
|
|
||||||
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val timeSinceLastSync = now - lastSyncTime
|
|
||||||
|
|
||||||
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
|
||||||
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
|
||||||
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
|
|
||||||
// Unregister BroadcastReceiver
|
|
||||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
|
||||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findViews() {
|
|
||||||
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
|
|
||||||
emptyStateCard = findViewById(R.id.emptyStateCard)
|
|
||||||
fabAddNote = findViewById(R.id.fabAddNote)
|
|
||||||
toolbar = findViewById(R.id.toolbar)
|
|
||||||
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
|
|
||||||
|
|
||||||
// 🔄 v1.3.1: Sync Status Banner
|
|
||||||
syncStatusBanner = findViewById(R.id.syncStatusBanner)
|
|
||||||
syncStatusText = findViewById(R.id.syncStatusText)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupToolbar() {
|
|
||||||
setSupportActionBar(toolbar)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupRecyclerView() {
|
|
||||||
adapter = NotesAdapter { note ->
|
|
||||||
openNoteEditor(note.id)
|
|
||||||
}
|
|
||||||
recyclerViewNotes.adapter = adapter
|
|
||||||
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
|
||||||
|
|
||||||
// 🔥 v1.1.2: Setup Pull-to-Refresh
|
|
||||||
setupPullToRefresh()
|
|
||||||
|
|
||||||
// Setup Swipe-to-Delete
|
|
||||||
setupSwipeToDelete()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
|
|
||||||
*/
|
|
||||||
private fun setupPullToRefresh() {
|
|
||||||
swipeRefreshLayout.setOnRefreshListener {
|
|
||||||
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
|
|
||||||
|
|
||||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
|
||||||
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
|
|
||||||
swipeRefreshLayout.isRefreshing = false
|
|
||||||
return@setOnRefreshListener
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
try {
|
|
||||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
|
||||||
|
|
||||||
if (serverUrl.isNullOrEmpty()) {
|
|
||||||
showToast("⚠️ Server noch nicht konfiguriert")
|
|
||||||
SyncStateManager.reset()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val syncService = WebDavSyncService(this@MainActivity)
|
|
||||||
|
|
||||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
|
||||||
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
|
||||||
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if server is reachable
|
|
||||||
if (!syncService.isServerReachable()) {
|
|
||||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform sync
|
|
||||||
val result = syncService.syncNotes()
|
|
||||||
|
|
||||||
if (result.isSuccess) {
|
|
||||||
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
|
|
||||||
loadNotes()
|
|
||||||
} else {
|
|
||||||
SyncStateManager.markError(result.errorMessage)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
|
|
||||||
SyncStateManager.markError(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Material 3 color scheme
|
|
||||||
swipeRefreshLayout.setColorSchemeResources(
|
|
||||||
com.google.android.material.R.color.material_dynamic_primary50
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupSwipeToDelete() {
|
|
||||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
|
||||||
0, // No drag
|
|
||||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT // Swipe left or right
|
|
||||||
) {
|
|
||||||
override fun onMove(
|
|
||||||
recyclerView: RecyclerView,
|
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
|
||||||
target: RecyclerView.ViewHolder
|
|
||||||
): Boolean = false
|
|
||||||
|
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
|
||||||
val position = viewHolder.bindingAdapterPosition
|
|
||||||
val swipedNote = adapter.currentList[position]
|
|
||||||
|
|
||||||
// Store original list BEFORE removing note
|
|
||||||
val originalList = adapter.currentList.toList()
|
|
||||||
|
|
||||||
// Remove from list for visual feedback (NOT from storage yet!)
|
|
||||||
val listWithoutNote = originalList.toMutableList().apply {
|
|
||||||
removeAt(position)
|
|
||||||
}
|
|
||||||
adapter.submitList(listWithoutNote)
|
|
||||||
|
|
||||||
// Show dialog with ability to restore
|
|
||||||
showServerDeletionDialog(swipedNote, originalList)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
|
||||||
// Require 80% swipe to trigger
|
|
||||||
return 0.8f
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
|
|
||||||
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
|
||||||
|
|
||||||
if (alwaysDeleteFromServer) {
|
|
||||||
// Auto-delete from server without asking
|
|
||||||
deleteNoteLocally(note, deleteFromServer = true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
|
|
||||||
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
|
|
||||||
|
|
||||||
MaterialAlertDialogBuilder(this)
|
|
||||||
.setTitle(getString(R.string.legacy_delete_dialog_title))
|
|
||||||
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
|
|
||||||
.setView(dialogView)
|
|
||||||
.setNeutralButton(getString(R.string.cancel)) { _, _ ->
|
|
||||||
// RESTORE: Re-submit original list (note is NOT deleted from storage)
|
|
||||||
adapter.submitList(originalList)
|
|
||||||
}
|
|
||||||
.setOnCancelListener {
|
|
||||||
// User pressed back - also restore
|
|
||||||
adapter.submitList(originalList)
|
|
||||||
}
|
|
||||||
.setPositiveButton("Nur lokal") { _, _ ->
|
|
||||||
if (checkboxAlways.isChecked) {
|
|
||||||
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
|
|
||||||
}
|
|
||||||
// NOW actually delete from storage
|
|
||||||
deleteNoteLocally(note, deleteFromServer = false)
|
|
||||||
}
|
|
||||||
.setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
|
|
||||||
if (checkboxAlways.isChecked) {
|
|
||||||
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
|
|
||||||
}
|
|
||||||
deleteNoteLocally(note, deleteFromServer = true)
|
|
||||||
}
|
|
||||||
.setCancelable(true)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
|
|
||||||
// Track pending deletion to prevent flicker
|
|
||||||
pendingDeletions.add(note.id)
|
|
||||||
|
|
||||||
// Delete from storage
|
|
||||||
storage.deleteNote(note.id)
|
|
||||||
|
|
||||||
// Reload to reflect changes
|
|
||||||
loadNotes()
|
|
||||||
|
|
||||||
// Show Snackbar with UNDO option
|
|
||||||
val message = if (deleteFromServer) {
|
|
||||||
getString(R.string.legacy_delete_with_server, note.title)
|
|
||||||
} else {
|
|
||||||
getString(R.string.legacy_delete_local_only, note.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
|
|
||||||
.setAction(getString(R.string.snackbar_undo)) {
|
|
||||||
// UNDO: Restore note
|
|
||||||
storage.saveNote(note)
|
|
||||||
pendingDeletions.remove(note.id)
|
|
||||||
loadNotes()
|
|
||||||
}
|
|
||||||
.addCallback(object : Snackbar.Callback() {
|
|
||||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
|
||||||
if (event != DISMISS_EVENT_ACTION) {
|
|
||||||
// Snackbar dismissed without UNDO
|
|
||||||
pendingDeletions.remove(note.id)
|
|
||||||
|
|
||||||
// Delete from server if requested
|
|
||||||
if (deleteFromServer) {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
try {
|
|
||||||
val webdavService = WebDavSyncService(this@MainActivity)
|
|
||||||
val success = webdavService.deleteNoteFromServer(note.id)
|
|
||||||
if (success) {
|
|
||||||
runOnUiThread {
|
|
||||||
Toast.makeText(
|
|
||||||
this@MainActivity,
|
|
||||||
getString(R.string.snackbar_deleted_from_server),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
runOnUiThread {
|
|
||||||
Toast.makeText(
|
|
||||||
this@MainActivity,
|
|
||||||
getString(R.string.snackbar_server_delete_failed),
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
runOnUiThread {
|
|
||||||
Toast.makeText(
|
|
||||||
this@MainActivity,
|
|
||||||
"Server-Fehler: ${e.message}",
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
|
|
||||||
*/
|
|
||||||
private fun setupFab() {
|
|
||||||
fabAddNote.setOnClickListener { view ->
|
|
||||||
showNoteTypePopup(view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
|
|
||||||
*/
|
|
||||||
private fun showNoteTypePopup(anchor: View) {
|
|
||||||
val popupMenu = PopupMenu(this, anchor, Gravity.END)
|
|
||||||
popupMenu.inflate(R.menu.menu_fab_note_types)
|
|
||||||
|
|
||||||
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
|
|
||||||
try {
|
|
||||||
val fields = popupMenu.javaClass.declaredFields
|
|
||||||
for (field in fields) {
|
|
||||||
if ("mPopup" == field.name) {
|
|
||||||
field.isAccessible = true
|
|
||||||
val menuPopupHelper = field.get(popupMenu)
|
|
||||||
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
|
|
||||||
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
|
|
||||||
setForceIcons.invoke(menuPopupHelper, true)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
popupMenu.setOnMenuItemClickListener { menuItem ->
|
|
||||||
val noteType = when (menuItem.itemId) {
|
|
||||||
R.id.action_create_text_note -> NoteType.TEXT
|
|
||||||
R.id.action_create_checklist -> NoteType.CHECKLIST
|
|
||||||
else -> return@setOnMenuItemClickListener false
|
|
||||||
}
|
|
||||||
|
|
||||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
|
||||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
|
||||||
startActivity(intent)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
popupMenu.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadNotes() {
|
|
||||||
val notes = storage.loadAllNotes()
|
|
||||||
|
|
||||||
// Filter out notes that are pending deletion (prevent flicker)
|
|
||||||
val filteredNotes = notes.filter { it.id !in pendingDeletions }
|
|
||||||
|
|
||||||
// Submit list with callback to scroll to top after list is updated
|
|
||||||
adapter.submitList(filteredNotes) {
|
|
||||||
// Scroll to top after list update is complete
|
|
||||||
// Wichtig: Nach dem Erstellen/Bearbeiten einer Notiz
|
|
||||||
if (filteredNotes.isNotEmpty()) {
|
|
||||||
recyclerViewNotes.scrollToPosition(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Material 3 Empty State Card
|
|
||||||
emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
|
|
||||||
android.view.View.VISIBLE
|
|
||||||
} else {
|
|
||||||
android.view.View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openNoteEditor(noteId: String?) {
|
|
||||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
|
||||||
noteId?.let {
|
|
||||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_ID, it)
|
|
||||||
}
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openSettings() {
|
|
||||||
// v1.5.0: Use new Jetpack Compose Settings
|
|
||||||
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
startActivityForResult(intent, REQUEST_SETTINGS)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun triggerManualSync() {
|
|
||||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
|
||||||
if (!SyncStateManager.tryStartSync("manual")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
try {
|
|
||||||
// Create sync service
|
|
||||||
val syncService = WebDavSyncService(this@MainActivity)
|
|
||||||
|
|
||||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
|
||||||
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
|
||||||
val message = getString(R.string.toast_already_synced)
|
|
||||||
SyncStateManager.markCompleted(message)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
|
||||||
syncService.isServerReachable()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isReachable) {
|
|
||||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
|
||||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server ist erreichbar → Sync durchführen
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
|
||||||
syncService.syncNotes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show result
|
|
||||||
if (result.isSuccess) {
|
|
||||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
|
||||||
loadNotes() // Reload notes
|
|
||||||
} else {
|
|
||||||
SyncStateManager.markError(result.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
SyncStateManager.markError(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.menu_main, menu)
|
|
||||||
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
return when (item.itemId) {
|
|
||||||
R.id.action_settings -> {
|
|
||||||
openSettings()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.action_sync -> {
|
|
||||||
triggerManualSync()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestNotificationPermission() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
|
||||||
requestPermissions(
|
|
||||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
|
||||||
REQUEST_NOTIFICATION_PERMISSION
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
|
||||||
|
|
||||||
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
|
||||||
// Restore was successful, reload notes
|
|
||||||
loadNotes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
|
|
||||||
*
|
|
||||||
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
|
|
||||||
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
|
|
||||||
*
|
|
||||||
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
|
|
||||||
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
|
|
||||||
*
|
|
||||||
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
|
|
||||||
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
|
|
||||||
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
|
|
||||||
*/
|
|
||||||
private fun migrateChecklistsForBackwardsCompat() {
|
|
||||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
|
||||||
|
|
||||||
// Nur einmal ausführen
|
|
||||||
if (prefs.getBoolean(migrationKey, false)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val allNotes = storage.loadAllNotes()
|
|
||||||
val checklistsToMigrate = allNotes.filter { note ->
|
|
||||||
note.noteType == NoteType.CHECKLIST &&
|
|
||||||
note.content.isBlank() &&
|
|
||||||
note.checklistItems?.isNotEmpty() == true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checklistsToMigrate.isNotEmpty()) {
|
|
||||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
|
||||||
|
|
||||||
for (note in checklistsToMigrate) {
|
|
||||||
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
|
|
||||||
// generiert und hochgeladen wird
|
|
||||||
val updatedNote = note.copy(
|
|
||||||
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
|
|
||||||
)
|
|
||||||
storage.saveNote(updatedNote)
|
|
||||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migration als erledigt markieren
|
|
||||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRequestPermissionsResult(
|
|
||||||
requestCode: Int,
|
|
||||||
permissions: Array<out String>,
|
|
||||||
grantResults: IntArray
|
|
||||||
) {
|
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
||||||
|
|
||||||
when (requestCode) {
|
|
||||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
|
||||||
if (grantResults.isNotEmpty() &&
|
|
||||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
showToast(getString(R.string.toast_notifications_enabled))
|
|
||||||
} else {
|
|
||||||
showToast(getString(R.string.toast_notifications_disabled))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
|
|
||||||
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
|
|
||||||
*/
|
|
||||||
private fun logLocaleInfo() {
|
|
||||||
if (!BuildConfig.DEBUG) return
|
|
||||||
|
|
||||||
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
|
|
||||||
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
|
|
||||||
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
|
|
||||||
|
|
||||||
// System Locale
|
|
||||||
val systemLocale = java.util.Locale.getDefault()
|
|
||||||
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
|
|
||||||
|
|
||||||
// Resources Locale
|
|
||||||
val resourcesLocale = resources.configuration.locales[0]
|
|
||||||
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
|
|
||||||
|
|
||||||
// Context Locale (API 24+)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
val contextLocales = resources.configuration.locales
|
|
||||||
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test String Loading
|
|
||||||
val testString = getString(R.string.toast_already_synced)
|
|
||||||
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
|
|
||||||
Logger.d(TAG, "║ Result: '$testString'")
|
|
||||||
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
|
|
||||||
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
|
|
||||||
|
|
||||||
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import android.view.View
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -24,14 +25,17 @@ import dev.dettmer.simplenotes.storage.NotesStorage
|
|||||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.utils.showToast
|
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
|
* Editor Activity für Notizen und Checklisten
|
||||||
*
|
*
|
||||||
* v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen
|
* v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen
|
||||||
*/
|
*/
|
||||||
class NoteEditorActivity : AppCompatActivity() {
|
class NoteEditorActivity : AppCompatActivity() {
|
||||||
|
|
||||||
// Views
|
// Views
|
||||||
private lateinit var toolbar: MaterialToolbar
|
private lateinit var toolbar: MaterialToolbar
|
||||||
private lateinit var tilTitle: TextInputLayout
|
private lateinit var tilTitle: TextInputLayout
|
||||||
@@ -41,38 +45,36 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
private lateinit var checklistContainer: LinearLayout
|
private lateinit var checklistContainer: LinearLayout
|
||||||
private lateinit var rvChecklistItems: RecyclerView
|
private lateinit var rvChecklistItems: RecyclerView
|
||||||
private lateinit var btnAddItem: MaterialButton
|
private lateinit var btnAddItem: MaterialButton
|
||||||
|
|
||||||
private lateinit var storage: NotesStorage
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
private var existingNote: Note? = null
|
private var existingNote: Note? = null
|
||||||
private var currentNoteType: NoteType = NoteType.TEXT
|
private var currentNoteType: NoteType = NoteType.TEXT
|
||||||
private val checklistItems = mutableListOf<ChecklistItem>()
|
private val checklistItems = mutableListOf<ChecklistItem>()
|
||||||
private var checklistAdapter: ChecklistEditorAdapter? = null
|
private var checklistAdapter: ChecklistEditorAdapter? = null
|
||||||
private var itemTouchHelper: ItemTouchHelper? = null
|
private var itemTouchHelper: ItemTouchHelper? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NoteEditorActivity"
|
private const val TAG = "NoteEditorActivity"
|
||||||
const val EXTRA_NOTE_ID = "extra_note_id"
|
const val EXTRA_NOTE_ID = "extra_note_id"
|
||||||
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val storage: NotesStorage by KoinJavaComponent.inject(NotesStorage::class.java)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// Apply Dynamic Colors for Android 12+ (Material You)
|
// Apply Dynamic Colors for Android 12+ (Material You)
|
||||||
DynamicColors.applyToActivityIfAvailable(this)
|
DynamicColors.applyToActivityIfAvailable(this)
|
||||||
|
|
||||||
setContentView(R.layout.activity_editor)
|
setContentView(R.layout.activity_editor)
|
||||||
|
|
||||||
storage = NotesStorage(this)
|
|
||||||
|
|
||||||
findViews()
|
findViews()
|
||||||
setupToolbar()
|
setupToolbar()
|
||||||
loadNoteOrDetermineType()
|
loadNoteOrDetermineType()
|
||||||
setupUIForNoteType()
|
setupUIForNoteType()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findViews() {
|
private fun findViews() {
|
||||||
toolbar = findViewById(R.id.toolbar)
|
toolbar = findViewById(R.id.toolbar)
|
||||||
tilTitle = findViewById(R.id.tilTitle)
|
tilTitle = findViewById(R.id.tilTitle)
|
||||||
@@ -83,33 +85,36 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
rvChecklistItems = findViewById(R.id.rvChecklistItems)
|
rvChecklistItems = findViewById(R.id.rvChecklistItems)
|
||||||
btnAddItem = findViewById(R.id.btnAddItem)
|
btnAddItem = findViewById(R.id.btnAddItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupToolbar() {
|
private fun setupToolbar() {
|
||||||
setSupportActionBar(toolbar)
|
setSupportActionBar(toolbar)
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNoteOrDetermineType() {
|
private fun loadNoteOrDetermineType() {
|
||||||
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
|
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
|
||||||
|
|
||||||
if (noteId != null) {
|
if (noteId != null) {
|
||||||
// Existierende Notiz laden
|
|
||||||
existingNote = storage.loadNote(noteId)
|
lifecycleScope.launch {
|
||||||
existingNote?.let { note ->
|
// Existierende Notiz laden
|
||||||
editTextTitle.setText(note.title)
|
existingNote = storage.loadNote(noteId)
|
||||||
currentNoteType = note.noteType
|
existingNote?.let { note ->
|
||||||
|
editTextTitle.setText(note.title)
|
||||||
when (note.noteType) {
|
currentNoteType = note.noteType
|
||||||
NoteType.TEXT -> {
|
|
||||||
editTextContent.setText(note.content)
|
when (note.noteType) {
|
||||||
supportActionBar?.title = getString(R.string.edit_note)
|
NoteType.TEXT -> {
|
||||||
}
|
editTextContent.setText(note.content)
|
||||||
NoteType.CHECKLIST -> {
|
supportActionBar?.title = getString(R.string.edit_note)
|
||||||
note.checklistItems?.let { items ->
|
}
|
||||||
checklistItems.clear()
|
NoteType.CHECKLIST -> {
|
||||||
checklistItems.addAll(items.sortedBy { it.order })
|
note.checklistItems?.let { items ->
|
||||||
|
checklistItems.clear()
|
||||||
|
checklistItems.addAll(items.sortedBy { it.order })
|
||||||
|
}
|
||||||
|
supportActionBar?.title = getString(R.string.edit_checklist)
|
||||||
}
|
}
|
||||||
supportActionBar?.title = getString(R.string.edit_checklist)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +127,7 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
|
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
|
||||||
NoteType.TEXT
|
NoteType.TEXT
|
||||||
}
|
}
|
||||||
|
|
||||||
when (currentNoteType) {
|
when (currentNoteType) {
|
||||||
NoteType.TEXT -> {
|
NoteType.TEXT -> {
|
||||||
supportActionBar?.title = getString(R.string.new_note)
|
supportActionBar?.title = getString(R.string.new_note)
|
||||||
@@ -135,7 +140,7 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupUIForNoteType() {
|
private fun setupUIForNoteType() {
|
||||||
when (currentNoteType) {
|
when (currentNoteType) {
|
||||||
NoteType.TEXT -> {
|
NoteType.TEXT -> {
|
||||||
@@ -149,7 +154,7 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupChecklistRecyclerView() {
|
private fun setupChecklistRecyclerView() {
|
||||||
checklistAdapter = ChecklistEditorAdapter(
|
checklistAdapter = ChecklistEditorAdapter(
|
||||||
items = checklistItems,
|
items = checklistItems,
|
||||||
@@ -173,12 +178,12 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
itemTouchHelper?.startDrag(viewHolder)
|
itemTouchHelper?.startDrag(viewHolder)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
rvChecklistItems.apply {
|
rvChecklistItems.apply {
|
||||||
layoutManager = LinearLayoutManager(this@NoteEditorActivity)
|
layoutManager = LinearLayoutManager(this@NoteEditorActivity)
|
||||||
adapter = checklistAdapter
|
adapter = checklistAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag & Drop Setup
|
// Drag & Drop Setup
|
||||||
val callback = object : ItemTouchHelper.SimpleCallback(
|
val callback = object : ItemTouchHelper.SimpleCallback(
|
||||||
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
|
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
|
||||||
@@ -194,48 +199,48 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
checklistAdapter?.moveItem(from, to)
|
checklistAdapter?.moveItem(from, to)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
// Nicht verwendet
|
// Nicht verwendet
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
|
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
|
||||||
}
|
}
|
||||||
|
|
||||||
itemTouchHelper = ItemTouchHelper(callback)
|
itemTouchHelper = ItemTouchHelper(callback)
|
||||||
itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
|
itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
|
||||||
|
|
||||||
// Add Item Button
|
// Add Item Button
|
||||||
btnAddItem.setOnClickListener {
|
btnAddItem.setOnClickListener {
|
||||||
addChecklistItemAt(checklistItems.size)
|
addChecklistItemAt(checklistItems.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addChecklistItemAt(position: Int) {
|
private fun addChecklistItemAt(position: Int) {
|
||||||
val newItem = ChecklistItem.createEmpty(position)
|
val newItem = ChecklistItem.createEmpty(position)
|
||||||
checklistAdapter?.insertItem(position, newItem)
|
checklistAdapter?.insertItem(position, newItem)
|
||||||
|
|
||||||
// Zum neuen Item scrollen und fokussieren
|
// Zum neuen Item scrollen und fokussieren
|
||||||
rvChecklistItems.scrollToPosition(position)
|
rvChecklistItems.scrollToPosition(position)
|
||||||
checklistAdapter?.focusItem(rvChecklistItems, position)
|
checklistAdapter?.focusItem(rvChecklistItems, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteChecklistItem(position: Int) {
|
private fun deleteChecklistItem(position: Int) {
|
||||||
checklistAdapter?.removeItem(position)
|
checklistAdapter?.removeItem(position)
|
||||||
|
|
||||||
// Wenn letztes Item gelöscht, automatisch neues hinzufügen
|
// Wenn letztes Item gelöscht, automatisch neues hinzufügen
|
||||||
if (checklistItems.isEmpty()) {
|
if (checklistItems.isEmpty()) {
|
||||||
addChecklistItemAt(0)
|
addChecklistItemAt(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.menu_editor, menu)
|
menuInflater.inflate(R.menu.menu_editor, menu)
|
||||||
// Delete nur für existierende Notizen
|
// Delete nur für existierende Notizen
|
||||||
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
android.R.id.home -> {
|
android.R.id.home -> {
|
||||||
@@ -253,19 +258,19 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveNote() {
|
private fun saveNote() {
|
||||||
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
when (currentNoteType) {
|
when (currentNoteType) {
|
||||||
NoteType.TEXT -> {
|
NoteType.TEXT -> {
|
||||||
val content = editTextContent.text?.toString()?.trim() ?: ""
|
val content = editTextContent.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
if (title.isEmpty() && content.isEmpty()) {
|
if (title.isEmpty() && content.isEmpty()) {
|
||||||
showToast(getString(R.string.note_is_empty))
|
showToast(getString(R.string.note_is_empty))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val note = if (existingNote != null) {
|
val note = if (existingNote != null) {
|
||||||
existingNote!!.copy(
|
existingNote!!.copy(
|
||||||
title = title,
|
title = title,
|
||||||
@@ -285,24 +290,24 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
syncStatus = SyncStatus.LOCAL_ONLY
|
syncStatus = SyncStatus.LOCAL_ONLY
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.saveNote(note)
|
lifecycleScope.launch { storage.saveNote(note) }
|
||||||
}
|
}
|
||||||
|
|
||||||
NoteType.CHECKLIST -> {
|
NoteType.CHECKLIST -> {
|
||||||
// Leere Items filtern
|
// Leere Items filtern
|
||||||
val validItems = checklistItems.filter { it.text.isNotBlank() }
|
val validItems = checklistItems.filter { it.text.isNotBlank() }
|
||||||
|
|
||||||
if (title.isEmpty() && validItems.isEmpty()) {
|
if (title.isEmpty() && validItems.isEmpty()) {
|
||||||
showToast(getString(R.string.note_is_empty))
|
showToast(getString(R.string.note_is_empty))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order neu setzen
|
// Order neu setzen
|
||||||
val orderedItems = validItems.mapIndexed { index, item ->
|
val orderedItems = validItems.mapIndexed { index, item ->
|
||||||
item.copy(order = index)
|
item.copy(order = index)
|
||||||
}
|
}
|
||||||
|
|
||||||
val note = if (existingNote != null) {
|
val note = if (existingNote != null) {
|
||||||
existingNote!!.copy(
|
existingNote!!.copy(
|
||||||
title = title,
|
title = title,
|
||||||
@@ -322,15 +327,15 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
syncStatus = SyncStatus.LOCAL_ONLY
|
syncStatus = SyncStatus.LOCAL_ONLY
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.saveNote(note)
|
lifecycleScope.launch { storage.saveNote(note) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(getString(R.string.note_saved))
|
showToast(getString(R.string.note_saved))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete() {
|
private fun confirmDelete() {
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle(getString(R.string.delete_note_title))
|
.setTitle(getString(R.string.delete_note_title))
|
||||||
@@ -341,10 +346,10 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
.setNegativeButton(getString(R.string.cancel), null)
|
.setNegativeButton(getString(R.string.cancel), null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteNote() {
|
private fun deleteNote() {
|
||||||
existingNote?.let {
|
existingNote?.let {
|
||||||
storage.deleteNote(it.id)
|
lifecycleScope.launch { storage.deleteNote(it.id) }
|
||||||
showToast(getString(R.string.note_deleted))
|
showToast(getString(R.string.note_deleted))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,18 +6,22 @@ import dev.dettmer.simplenotes.utils.Logger
|
|||||||
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
|
import dev.dettmer.simplenotes.di.appModule
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.android.ext.koin.androidLogger
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
class SimpleNotesApplication : Application() {
|
class SimpleNotesApplication : Application() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SimpleNotesApp"
|
private const val TAG = "SimpleNotesApp"
|
||||||
}
|
}
|
||||||
|
|
||||||
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
|
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🌍 v1.7.1: Apply app locale to Application Context
|
* 🌍 v1.7.1: Apply app locale to Application Context
|
||||||
*
|
*
|
||||||
* This ensures ViewModels and other components using Application Context
|
* This ensures ViewModels and other components using Application Context
|
||||||
* get the correct locale-specific strings.
|
* get the correct locale-specific strings.
|
||||||
*/
|
*/
|
||||||
@@ -26,71 +30,77 @@ class SimpleNotesApplication : Application() {
|
|||||||
// This is handled by AppCompatDelegate which reads from system storage
|
// This is handled by AppCompatDelegate which reads from system storage
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
startKoin {
|
||||||
|
androidLogger() // Log Koin events
|
||||||
|
androidContext(this@SimpleNotesApplication) // Provide context to modules
|
||||||
|
modules(appModule)
|
||||||
|
}
|
||||||
|
|
||||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
|
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
|
||||||
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly
|
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly
|
||||||
// appear as offline even though they have a configured server
|
// appear as offline even though they have a configured server
|
||||||
migrateOfflineModeSetting(prefs)
|
migrateOfflineModeSetting(prefs)
|
||||||
|
|
||||||
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
||||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||||
Logger.enableFileLogging(this)
|
Logger.enableFileLogging(this)
|
||||||
Logger.d(TAG, "📝 File logging enabled at Application startup")
|
Logger.d(TAG, "📝 File logging enabled at Application startup")
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "🚀 Application onCreate()")
|
Logger.d(TAG, "🚀 Application onCreate()")
|
||||||
|
|
||||||
// Initialize notification channel
|
// Initialize notification channel
|
||||||
NotificationHelper.createNotificationChannel(this)
|
NotificationHelper.createNotificationChannel(this)
|
||||||
Logger.d(TAG, "✅ Notification channel created")
|
Logger.d(TAG, "✅ Notification channel created")
|
||||||
|
|
||||||
// Initialize NetworkMonitor (WorkManager-based)
|
// Initialize NetworkMonitor (WorkManager-based)
|
||||||
// VORTEIL: WorkManager läuft auch ohne aktive App!
|
// VORTEIL: WorkManager läuft auch ohne aktive App!
|
||||||
networkMonitor = NetworkMonitor(applicationContext)
|
networkMonitor = NetworkMonitor(applicationContext)
|
||||||
|
|
||||||
// Start WorkManager periodic sync
|
// Start WorkManager periodic sync
|
||||||
// Dies läuft im Hintergrund auch wenn App geschlossen ist
|
// Dies läuft im Hintergrund auch wenn App geschlossen ist
|
||||||
networkMonitor.startMonitoring()
|
networkMonitor.startMonitoring()
|
||||||
|
|
||||||
Logger.d(TAG, "✅ WorkManager-based auto-sync initialized")
|
Logger.d(TAG, "✅ WorkManager-based auto-sync initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTerminate() {
|
override fun onTerminate() {
|
||||||
super.onTerminate()
|
super.onTerminate()
|
||||||
|
|
||||||
Logger.d(TAG, "🛑 Application onTerminate()")
|
Logger.d(TAG, "🛑 Application onTerminate()")
|
||||||
|
|
||||||
// WorkManager läuft weiter auch nach onTerminate!
|
// WorkManager läuft weiter auch nach onTerminate!
|
||||||
// Nur bei deaktiviertem Auto-Sync stoppen wir es
|
// Nur bei deaktiviertem Auto-Sync stoppen wir es
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
|
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
|
||||||
*
|
*
|
||||||
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
|
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
|
||||||
* and NoteEditorViewModel use `true` as default, causing existing users
|
* and NoteEditorViewModel use `true` as default, causing existing users
|
||||||
* with configured servers to appear in offline mode after update.
|
* with configured servers to appear in offline mode after update.
|
||||||
*
|
*
|
||||||
* Fix: Set the key BEFORE any ViewModel is initialized based on whether
|
* Fix: Set the key BEFORE any ViewModel is initialized based on whether
|
||||||
* a server was already configured.
|
* a server was already configured.
|
||||||
*/
|
*/
|
||||||
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
|
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
|
||||||
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
|
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
|
||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
|
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
|
||||||
serverUrl != "http://" &&
|
serverUrl != "http://" &&
|
||||||
serverUrl != "https://"
|
serverUrl != "https://"
|
||||||
|
|
||||||
// If server was configured → offlineMode = false (continue syncing)
|
// If server was configured → offlineMode = false (continue syncing)
|
||||||
// If no server → offlineMode = true (new users / offline users)
|
// If no server → offlineMode = true (new users / offline users)
|
||||||
val offlineModeValue = !hasServerConfig
|
val offlineModeValue = !hasServerConfig
|
||||||
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
|
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
|
||||||
|
|
||||||
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
|
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,24 @@ import dev.dettmer.simplenotes.storage.NotesStorage
|
|||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import kotlin.getValue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BackupManager: Lokale Backup & Restore Funktionalität
|
* BackupManager: Lokale Backup & Restore Funktionalität
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Backup aller Notizen in JSON-Datei
|
* - Backup aller Notizen in JSON-Datei
|
||||||
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
|
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
|
||||||
* - Auto-Backup vor Restore (Sicherheitsnetz)
|
* - Auto-Backup vor Restore (Sicherheitsnetz)
|
||||||
* - Backup-Validierung
|
* - Backup-Validierung
|
||||||
*/
|
*/
|
||||||
class BackupManager(private val context: Context) {
|
class BackupManager(private val context: Context): KoinComponent {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BackupManager"
|
private const val TAG = "BackupManager"
|
||||||
private const val BACKUP_VERSION = 1
|
private const val BACKUP_VERSION = 1
|
||||||
@@ -33,14 +36,14 @@ class BackupManager(private val context: Context) {
|
|||||||
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
||||||
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
|
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 gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
|
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt Backup aller Notizen
|
* Erstellt Backup aller Notizen
|
||||||
*
|
*
|
||||||
* @param uri Output-URI (via Storage Access Framework)
|
* @param uri Output-URI (via Storage Access Framework)
|
||||||
* @param password Optional password for encryption (null = unencrypted)
|
* @param password Optional password for encryption (null = unencrypted)
|
||||||
* @return BackupResult mit Erfolg/Fehler Info
|
* @return BackupResult mit Erfolg/Fehler Info
|
||||||
@@ -49,10 +52,10 @@ class BackupManager(private val context: Context) {
|
|||||||
return@withContext try {
|
return@withContext try {
|
||||||
val encryptedSuffix = if (password != null) " (encrypted)" else ""
|
val encryptedSuffix = if (password != null) " (encrypted)" else ""
|
||||||
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
|
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
|
||||||
|
|
||||||
val allNotes = storage.loadAllNotes()
|
val allNotes = storage.loadAllNotes()
|
||||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||||
|
|
||||||
val backupData = BackupData(
|
val backupData = BackupData(
|
||||||
backupVersion = BACKUP_VERSION,
|
backupVersion = BACKUP_VERSION,
|
||||||
createdAt = System.currentTimeMillis(),
|
createdAt = System.currentTimeMillis(),
|
||||||
@@ -60,27 +63,27 @@ class BackupManager(private val context: Context) {
|
|||||||
appVersion = BuildConfig.VERSION_NAME,
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
notes = allNotes
|
notes = allNotes
|
||||||
)
|
)
|
||||||
|
|
||||||
val jsonString = gson.toJson(backupData)
|
val jsonString = gson.toJson(backupData)
|
||||||
|
|
||||||
// 🔐 v1.7.0: Encrypt if password is provided
|
// 🔐 v1.7.0: Encrypt if password is provided
|
||||||
val dataToWrite = if (password != null) {
|
val dataToWrite = if (password != null) {
|
||||||
encryptionManager.encrypt(jsonString.toByteArray(), password)
|
encryptionManager.encrypt(jsonString.toByteArray(), password)
|
||||||
} else {
|
} else {
|
||||||
jsonString.toByteArray()
|
jsonString.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
outputStream.write(dataToWrite)
|
outputStream.write(dataToWrite)
|
||||||
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
|
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
|
||||||
}
|
}
|
||||||
|
|
||||||
BackupResult(
|
BackupResult(
|
||||||
success = true,
|
success = true,
|
||||||
notesCount = allNotes.size,
|
notesCount = allNotes.size,
|
||||||
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
|
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to create backup", e)
|
Logger.e(TAG, "Failed to create backup", e)
|
||||||
BackupResult(
|
BackupResult(
|
||||||
@@ -89,11 +92,11 @@ class BackupManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt automatisches Backup (vor Restore)
|
* Erstellt automatisches Backup (vor Restore)
|
||||||
* Gespeichert in app-internem Storage
|
* Gespeichert in app-internem Storage
|
||||||
*
|
*
|
||||||
* @return Uri des Auto-Backups oder null bei Fehler
|
* @return Uri des Auto-Backups oder null bei Fehler
|
||||||
*/
|
*/
|
||||||
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
|
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
|
||||||
@@ -101,14 +104,14 @@ class BackupManager(private val context: Context) {
|
|||||||
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
|
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
|
||||||
if (!exists()) mkdirs()
|
if (!exists()) mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||||
.format(Date())
|
.format(Date())
|
||||||
val filename = "auto_backup_before_restore_$timestamp.json"
|
val filename = "auto_backup_before_restore_$timestamp.json"
|
||||||
val file = File(autoBackupDir, filename)
|
val file = File(autoBackupDir, filename)
|
||||||
|
|
||||||
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
|
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
|
||||||
|
|
||||||
val allNotes = storage.loadAllNotes()
|
val allNotes = storage.loadAllNotes()
|
||||||
val backupData = BackupData(
|
val backupData = BackupData(
|
||||||
backupVersion = BACKUP_VERSION,
|
backupVersion = BACKUP_VERSION,
|
||||||
@@ -117,24 +120,24 @@ class BackupManager(private val context: Context) {
|
|||||||
appVersion = BuildConfig.VERSION_NAME,
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
notes = allNotes
|
notes = allNotes
|
||||||
)
|
)
|
||||||
|
|
||||||
file.writeText(gson.toJson(backupData))
|
file.writeText(gson.toJson(backupData))
|
||||||
|
|
||||||
// Cleanup alte Auto-Backups
|
// Cleanup alte Auto-Backups
|
||||||
cleanupOldAutoBackups(autoBackupDir)
|
cleanupOldAutoBackups(autoBackupDir)
|
||||||
|
|
||||||
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
|
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
|
||||||
Uri.fromFile(file)
|
Uri.fromFile(file)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to create auto-backup", e)
|
Logger.e(TAG, "Failed to create auto-backup", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stellt Notizen aus Backup wieder her
|
* Stellt Notizen aus Backup wieder her
|
||||||
*
|
*
|
||||||
* @param uri Backup-Datei URI
|
* @param uri Backup-Datei URI
|
||||||
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||||
* @param password Optional password if backup is encrypted
|
* @param password Optional password if backup is encrypted
|
||||||
@@ -143,7 +146,7 @@ class BackupManager(private val context: Context) {
|
|||||||
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
|
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
|
||||||
return@withContext try {
|
return@withContext try {
|
||||||
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||||
|
|
||||||
// 1. Backup-Datei lesen
|
// 1. Backup-Datei lesen
|
||||||
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
inputStream.readBytes()
|
inputStream.readBytes()
|
||||||
@@ -151,7 +154,7 @@ class BackupManager(private val context: Context) {
|
|||||||
success = false,
|
success = false,
|
||||||
error = "Datei konnte nicht gelesen werden"
|
error = "Datei konnte nicht gelesen werden"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔐 v1.7.0: Check if encrypted and decrypt if needed
|
// 🔐 v1.7.0: Check if encrypted and decrypt if needed
|
||||||
val jsonString = try {
|
val jsonString = try {
|
||||||
if (encryptionManager.isEncrypted(fileData)) {
|
if (encryptionManager.isEncrypted(fileData)) {
|
||||||
@@ -172,7 +175,7 @@ class BackupManager(private val context: Context) {
|
|||||||
error = "Entschlüsselung fehlgeschlagen: ${e.message}"
|
error = "Entschlüsselung fehlgeschlagen: ${e.message}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Backup validieren & parsen
|
// 2. Backup validieren & parsen
|
||||||
val validationResult = validateBackup(jsonString)
|
val validationResult = validateBackup(jsonString)
|
||||||
if (!validationResult.isValid) {
|
if (!validationResult.isValid) {
|
||||||
@@ -181,26 +184,26 @@ class BackupManager(private val context: Context) {
|
|||||||
error = validationResult.errorMessage ?: context.getString(R.string.error_invalid_backup_file)
|
error = validationResult.errorMessage ?: context.getString(R.string.error_invalid_backup_file)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||||
Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
|
Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
|
||||||
|
|
||||||
// 3. Auto-Backup erstellen (Sicherheitsnetz)
|
// 3. Auto-Backup erstellen (Sicherheitsnetz)
|
||||||
val autoBackupUri = createAutoBackup()
|
val autoBackupUri = createAutoBackup()
|
||||||
if (autoBackupUri == null) {
|
if (autoBackupUri == null) {
|
||||||
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Restore durchführen (je nach Modus)
|
// 4. Restore durchführen (je nach Modus)
|
||||||
val result = when (mode) {
|
val result = when (mode) {
|
||||||
RestoreMode.MERGE -> restoreMerge(backupData.notes)
|
RestoreMode.MERGE -> restoreMerge(backupData.notes)
|
||||||
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
|
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
|
||||||
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
|
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
|
Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
|
||||||
result
|
result
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to restore backup", e)
|
Logger.e(TAG, "Failed to restore backup", e)
|
||||||
RestoreResult(
|
RestoreResult(
|
||||||
@@ -209,7 +212,7 @@ class BackupManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔐 v1.7.0: Check if backup file is encrypted
|
* 🔐 v1.7.0: Check if backup file is encrypted
|
||||||
*/
|
*/
|
||||||
@@ -225,14 +228,14 @@ class BackupManager(private val context: Context) {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validiert Backup-Datei
|
* Validiert Backup-Datei
|
||||||
*/
|
*/
|
||||||
private fun validateBackup(jsonString: String): ValidationResult {
|
private fun validateBackup(jsonString: String): ValidationResult {
|
||||||
return try {
|
return try {
|
||||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||||
|
|
||||||
// Version kompatibel?
|
// Version kompatibel?
|
||||||
if (backupData.backupVersion > BACKUP_VERSION) {
|
if (backupData.backupVersion > BACKUP_VERSION) {
|
||||||
return ValidationResult(
|
return ValidationResult(
|
||||||
@@ -240,7 +243,7 @@ class BackupManager(private val context: Context) {
|
|||||||
errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
|
errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notizen-Array vorhanden?
|
// Notizen-Array vorhanden?
|
||||||
if (backupData.notes.isEmpty()) {
|
if (backupData.notes.isEmpty()) {
|
||||||
return ValidationResult(
|
return ValidationResult(
|
||||||
@@ -248,21 +251,21 @@ class BackupManager(private val context: Context) {
|
|||||||
errorMessage = context.getString(R.string.error_backup_empty)
|
errorMessage = context.getString(R.string.error_backup_empty)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle Notizen haben ID, title, content?
|
// Alle Notizen haben ID, title, content?
|
||||||
val invalidNotes = backupData.notes.filter { note ->
|
val invalidNotes = backupData.notes.filter { note ->
|
||||||
note.id.isBlank() || note.title.isBlank()
|
note.id.isBlank() || note.title.isBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invalidNotes.isNotEmpty()) {
|
if (invalidNotes.isNotEmpty()) {
|
||||||
return ValidationResult(
|
return ValidationResult(
|
||||||
isValid = false,
|
isValid = false,
|
||||||
errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size)
|
errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidationResult(isValid = true)
|
ValidationResult(isValid = true)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ValidationResult(
|
ValidationResult(
|
||||||
isValid = false,
|
isValid = false,
|
||||||
@@ -270,22 +273,22 @@ class BackupManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore-Modus: MERGE
|
* Restore-Modus: MERGE
|
||||||
* Fügt neue Notizen hinzu, behält bestehende
|
* 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 existingNotes = storage.loadAllNotes()
|
||||||
val existingIds = existingNotes.map { it.id }.toSet()
|
val existingIds = existingNotes.map { it.id }.toSet()
|
||||||
|
|
||||||
val newNotes = backupNotes.filter { it.id !in existingIds }
|
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||||
val skippedNotes = backupNotes.size - newNotes.size
|
val skippedNotes = backupNotes.size - newNotes.size
|
||||||
|
|
||||||
newNotes.forEach { note ->
|
newNotes.forEach { note ->
|
||||||
storage.saveNote(note)
|
storage.saveNote(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
return RestoreResult(
|
return RestoreResult(
|
||||||
success = true,
|
success = true,
|
||||||
importedNotes = newNotes.size,
|
importedNotes = newNotes.size,
|
||||||
@@ -293,20 +296,20 @@ class BackupManager(private val context: Context) {
|
|||||||
message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes)
|
message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore-Modus: REPLACE
|
* Restore-Modus: REPLACE
|
||||||
* Löscht alle bestehenden Notizen, importiert Backup
|
* 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
|
// Alle bestehenden Notizen löschen
|
||||||
storage.deleteAllNotes()
|
storage.deleteAllNotes()
|
||||||
|
|
||||||
// Backup-Notizen importieren
|
// Backup-Notizen importieren
|
||||||
backupNotes.forEach { note ->
|
backupNotes.forEach { note ->
|
||||||
storage.saveNote(note)
|
storage.saveNote(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
return RestoreResult(
|
return RestoreResult(
|
||||||
success = true,
|
success = true,
|
||||||
importedNotes = backupNotes.size,
|
importedNotes = backupNotes.size,
|
||||||
@@ -319,18 +322,18 @@ class BackupManager(private val context: Context) {
|
|||||||
* Restore-Modus: OVERWRITE_DUPLICATES
|
* Restore-Modus: OVERWRITE_DUPLICATES
|
||||||
* Backup überschreibt bei ID-Konflikten
|
* 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 existingNotes = storage.loadAllNotes()
|
||||||
val existingIds = existingNotes.map { it.id }.toSet()
|
val existingIds = existingNotes.map { it.id }.toSet()
|
||||||
|
|
||||||
val newNotes = backupNotes.filter { it.id !in existingIds }
|
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||||
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
|
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
|
||||||
|
|
||||||
// Alle Backup-Notizen speichern (überschreibt automatisch)
|
// Alle Backup-Notizen speichern (überschreibt automatisch)
|
||||||
backupNotes.forEach { note ->
|
backupNotes.forEach { note ->
|
||||||
storage.saveNote(note)
|
storage.saveNote(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
return RestoreResult(
|
return RestoreResult(
|
||||||
success = true,
|
success = true,
|
||||||
importedNotes = newNotes.size,
|
importedNotes = newNotes.size,
|
||||||
@@ -339,7 +342,7 @@ class BackupManager(private val context: Context) {
|
|||||||
message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
|
message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Löscht Auto-Backups älter als RETENTION_DAYS
|
* Löscht Auto-Backups älter als RETENTION_DAYS
|
||||||
*/
|
*/
|
||||||
@@ -347,7 +350,7 @@ class BackupManager(private val context: Context) {
|
|||||||
try {
|
try {
|
||||||
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
|
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
|
||||||
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
|
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
|
||||||
|
|
||||||
autoBackupDir.listFiles()?.forEach { file ->
|
autoBackupDir.listFiles()?.forEach { file ->
|
||||||
if (file.lastModified() < cutoffTime) {
|
if (file.lastModified() < cutoffTime) {
|
||||||
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")
|
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package dev.dettmer.simplenotes.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import dev.dettmer.simplenotes.storage.AppDatabase
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.ui.main.MainViewModel
|
||||||
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
|
import org.koin.android.ext.koin.androidApplication
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val appModule = module {
|
||||||
|
single {
|
||||||
|
Room.databaseBuilder(
|
||||||
|
androidContext(),
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"notes_database"
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
single { get<AppDatabase>().noteDao() }
|
||||||
|
single { get<AppDatabase>().deletedNoteDao() }
|
||||||
|
|
||||||
|
single { NotesStorage(androidContext(), get(), get()) }
|
||||||
|
|
||||||
|
// Provide SharedPreferences
|
||||||
|
single {
|
||||||
|
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
viewModel { MainViewModel(androidApplication()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package dev.dettmer.simplenotes.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import dev.dettmer.simplenotes.storage.converter.NoteConverters
|
||||||
|
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
|
||||||
|
import dev.dettmer.simplenotes.storage.dao.NoteDao
|
||||||
|
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
|
||||||
|
import dev.dettmer.simplenotes.storage.entity.NoteEntity
|
||||||
|
|
||||||
|
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
|
||||||
|
@TypeConverters(NoteConverters::class)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
abstract fun noteDao(): NoteDao
|
||||||
|
abstract fun deletedNoteDao(): DeletedNoteDao
|
||||||
|
}
|
||||||
@@ -3,75 +3,99 @@ package dev.dettmer.simplenotes.storage
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dev.dettmer.simplenotes.models.DeletionTracker
|
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||||
import dev.dettmer.simplenotes.models.Note
|
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.DeviceIdGenerator
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class NotesStorage(private val context: Context) {
|
class NotesStorage(
|
||||||
|
private val context: Context,
|
||||||
|
private val noteDao: NoteDao,
|
||||||
|
private val deletedNoteDao: DeletedNoteDao,
|
||||||
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NotesStorage"
|
private const val TAG = "NotesStorage"
|
||||||
// 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen
|
|
||||||
private val deletionTrackerMutex = Mutex()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val notesDir: File = File(context.filesDir, "notes").apply {
|
|
||||||
if (!exists()) mkdirs()
|
suspend fun saveNote(note: Note) {
|
||||||
|
noteDao.saveNote(
|
||||||
|
NoteEntity(
|
||||||
|
id = note.id,
|
||||||
|
title = note.title,
|
||||||
|
content = note.content,
|
||||||
|
createdAt = note.createdAt,
|
||||||
|
updatedAt = note.updatedAt,
|
||||||
|
deviceId = note.deviceId,
|
||||||
|
syncStatus = note.syncStatus,
|
||||||
|
noteType = note.noteType,
|
||||||
|
checklistItems = note.checklistItems,
|
||||||
|
checklistSortOption = note.checklistSortOption
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveNote(note: Note) {
|
suspend fun loadNote(id: String): Note? {
|
||||||
val file = File(notesDir, "${note.id}.json")
|
return noteDao.getNote(id)?.let { note ->
|
||||||
file.writeText(note.toJson())
|
Note(
|
||||||
}
|
id = note.id,
|
||||||
|
title = note.title,
|
||||||
fun loadNote(id: String): Note? {
|
content = note.content,
|
||||||
val file = File(notesDir, "$id.json")
|
createdAt = note.createdAt,
|
||||||
return if (file.exists()) {
|
updatedAt = note.updatedAt,
|
||||||
Note.fromJson(file.readText())
|
deviceId = note.deviceId,
|
||||||
} else {
|
syncStatus = note.syncStatus,
|
||||||
null
|
noteType = note.noteType,
|
||||||
|
checklistItems = note.checklistItems,
|
||||||
|
checklistSortOption = note.checklistSortOption
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
suspend fun loadAllNotes(): List<Note> {
|
||||||
* Lädt alle Notizen aus dem lokalen Speicher.
|
return noteDao.getAllNotes().map { note ->
|
||||||
*
|
Note(
|
||||||
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt,
|
id = note.id,
|
||||||
* damit der User die Sortierung konfigurieren kann.
|
title = note.title,
|
||||||
*/
|
content = note.content,
|
||||||
fun loadAllNotes(): List<Note> {
|
createdAt = note.createdAt,
|
||||||
return notesDir.listFiles()
|
updatedAt = note.updatedAt,
|
||||||
?.filter { it.extension == "json" }
|
deviceId = note.deviceId,
|
||||||
?.mapNotNull { Note.fromJson(it.readText()) }
|
syncStatus = note.syncStatus,
|
||||||
?: emptyList()
|
noteType = note.noteType,
|
||||||
|
checklistItems = note.checklistItems,
|
||||||
|
checklistSortOption = note.checklistSortOption
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteNote(id: String): Boolean {
|
suspend fun deleteNote(id: String): Boolean {
|
||||||
val file = File(notesDir, "$id.json")
|
val deleted = noteDao.deleteNoteById(id) > 0
|
||||||
val deleted = file.delete()
|
|
||||||
|
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
Logger.d(TAG, "🗑️ Deleted note: $id")
|
|
||||||
|
|
||||||
// Track deletion to prevent zombie notes
|
|
||||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||||
trackDeletion(id, deviceId)
|
deletedNoteDao.trackDeletion(DeletedNoteEntity(id, deviceId))
|
||||||
}
|
}
|
||||||
|
|
||||||
return deleted
|
return deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteAllNotes(): Boolean {
|
suspend fun deleteAllNotes(): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val notes = loadAllNotes()
|
val notes = noteDao.getAllNotes()
|
||||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
|
||||||
|
noteDao.deleteAllNotes()
|
||||||
|
|
||||||
for (note in notes) {
|
for (note in notes) {
|
||||||
deleteNote(note.id) // Uses trackDeletion() automatically
|
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||||
|
deletedNoteDao.trackDeletion(DeletedNoteEntity(note.id, deviceId))
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
|
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -79,19 +103,19 @@ class NotesStorage(private val context: Context) {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Deletion Tracking ===
|
// === Deletion Tracking ===
|
||||||
|
|
||||||
private fun getDeletionTrackerFile(): File {
|
private fun getDeletionTrackerFile(): File {
|
||||||
return File(context.filesDir, "deleted_notes.json")
|
return File(context.filesDir, "deleted_notes.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadDeletionTracker(): DeletionTracker {
|
fun loadDeletionTracker(): DeletionTracker {
|
||||||
val file = getDeletionTrackerFile()
|
val file = getDeletionTrackerFile()
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
return DeletionTracker()
|
return DeletionTracker()
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val json = file.readText()
|
val json = file.readText()
|
||||||
DeletionTracker.fromJson(json) ?: DeletionTracker()
|
DeletionTracker.fromJson(json) ?: DeletionTracker()
|
||||||
@@ -100,83 +124,64 @@ class NotesStorage(private val context: Context) {
|
|||||||
DeletionTracker()
|
DeletionTracker()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveDeletionTracker(tracker: DeletionTracker) {
|
fun saveDeletionTracker(tracker: DeletionTracker) {
|
||||||
try {
|
try {
|
||||||
val file = getDeletionTrackerFile()
|
val file = getDeletionTrackerFile()
|
||||||
file.writeText(tracker.toJson())
|
file.writeText(tracker.toJson())
|
||||||
|
|
||||||
if (tracker.deletedNotes.size > 1000) {
|
if (tracker.deletedNotes.size > 1000) {
|
||||||
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
|
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
|
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to save deletion tracker", e)
|
Logger.e(TAG, "Failed to save deletion tracker", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
|
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
|
||||||
*
|
*
|
||||||
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
|
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
|
||||||
* auf den Deletion Tracker.
|
* auf den Deletion Tracker.
|
||||||
*
|
*
|
||||||
* @param noteId ID der gelöschten Notiz
|
* @param noteId ID der gelöschten Notiz
|
||||||
* @param deviceId Geräte-ID für Konflikt-Erkennung
|
* @param deviceId Geräte-ID für Konflikt-Erkennung
|
||||||
*/
|
*/
|
||||||
suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
|
suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
|
||||||
deletionTrackerMutex.withLock {
|
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
|
||||||
val tracker = loadDeletionTracker()
|
|
||||||
tracker.addDeletion(noteId, deviceId)
|
|
||||||
saveDeletionTracker(tracker)
|
|
||||||
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy-Methode ohne Mutex-Schutz.
|
* Legacy-Methode ohne Mutex-Schutz.
|
||||||
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
|
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
|
||||||
*
|
*
|
||||||
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
|
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
|
||||||
*/
|
*/
|
||||||
fun trackDeletion(noteId: String, deviceId: String) {
|
suspend fun trackDeletion(noteId: String, deviceId: String) {
|
||||||
val tracker = loadDeletionTracker()
|
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
|
||||||
tracker.addDeletion(noteId, deviceId)
|
|
||||||
saveDeletionTracker(tracker)
|
|
||||||
Logger.d(TAG, "📝 Tracked deletion: $noteId")
|
Logger.d(TAG, "📝 Tracked deletion: $noteId")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isNoteDeleted(noteId: String): Boolean {
|
suspend fun isNoteDeleted(noteId: String): Boolean {
|
||||||
val tracker = loadDeletionTracker()
|
return deletedNoteDao.isNoteDeleted(noteId)
|
||||||
return tracker.isDeleted(noteId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearDeletionTracker() {
|
suspend fun clearDeletionTracker() {
|
||||||
saveDeletionTracker(DeletionTracker())
|
deletedNoteDao.clearTracker()
|
||||||
|
|
||||||
Logger.d(TAG, "🗑️ Deletion tracker cleared")
|
Logger.d(TAG, "🗑️ Deletion tracker cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
|
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
|
||||||
* This ensures notes are uploaded to the new server on next sync
|
* This ensures notes are uploaded to the new server on next sync
|
||||||
*/
|
*/
|
||||||
fun resetAllSyncStatusToPending(): Int {
|
suspend fun resetAllSyncStatusToPending(): Int {
|
||||||
val notes = loadAllNotes()
|
var updatedCount = noteDao.updateSyncStatus(SyncStatus.SYNCED, SyncStatus.PENDING)
|
||||||
var updatedCount = 0
|
|
||||||
|
|
||||||
notes.forEach { note ->
|
|
||||||
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
|
|
||||||
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
|
|
||||||
saveNote(updatedNote)
|
|
||||||
updatedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
|
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
|
||||||
return updatedCount
|
return updatedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getNotesDir(): File = notesDir
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,22 @@
|
|||||||
|
package dev.dettmer.simplenotes.storage.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
|
|
||||||
|
@Entity(tableName = "notes")
|
||||||
|
data class NoteEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val deviceId: String,
|
||||||
|
val syncStatus: SyncStatus,
|
||||||
|
val noteType: NoteType,
|
||||||
|
val checklistItems: List<ChecklistItem>?, // Handled by TypeConverter
|
||||||
|
val checklistSortOption: String?
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.editor
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -29,67 +30,69 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.getValue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ViewModel for NoteEditor Compose Screen
|
* ViewModel for NoteEditor Compose Screen
|
||||||
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||||
*
|
*
|
||||||
* Manages note editing state including title, content, and checklist items.
|
* Manages note editing state including title, content, and checklist items.
|
||||||
*/
|
*/
|
||||||
class NoteEditorViewModel(
|
class NoteEditorViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val savedStateHandle: SavedStateHandle
|
private val savedStateHandle: SavedStateHandle
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NoteEditorViewModel"
|
private const val TAG = "NoteEditorViewModel"
|
||||||
const val ARG_NOTE_ID = "noteId"
|
const val ARG_NOTE_ID = "noteId"
|
||||||
const val ARG_NOTE_TYPE = "noteType"
|
const val ARG_NOTE_TYPE = "noteType"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val storage = NotesStorage(application)
|
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// State
|
// State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(NoteEditorUiState())
|
private val _uiState = MutableStateFlow(NoteEditorUiState())
|
||||||
val uiState: StateFlow<NoteEditorUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<NoteEditorUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
|
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
|
||||||
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
|
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
|
||||||
|
|
||||||
// 🌟 v1.6.0: Offline Mode State
|
// 🌟 v1.6.0: Offline Mode State
|
||||||
private val _isOfflineMode = MutableStateFlow(
|
private val _isOfflineMode = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||||
)
|
)
|
||||||
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||||
|
|
||||||
// 🔀 v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope)
|
// 🔀 v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope)
|
||||||
private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL)
|
private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL)
|
||||||
val lastChecklistSortOption: StateFlow<ChecklistSortOption> = _lastChecklistSortOption.asStateFlow()
|
val lastChecklistSortOption: StateFlow<ChecklistSortOption> = _lastChecklistSortOption.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Events
|
// Events
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _events = MutableSharedFlow<NoteEditorEvent>()
|
private val _events = MutableSharedFlow<NoteEditorEvent>()
|
||||||
val events: SharedFlow<NoteEditorEvent> = _events.asSharedFlow()
|
val events: SharedFlow<NoteEditorEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
// Internal state
|
// Internal state
|
||||||
private var existingNote: Note? = null
|
private var existingNote: Note? = null
|
||||||
private var currentNoteType: NoteType = NoteType.TEXT
|
private var currentNoteType: NoteType = NoteType.TEXT
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadNote()
|
loadNote()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNote() {
|
private fun loadNote() {
|
||||||
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID)
|
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID)
|
||||||
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
|
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||||
|
|
||||||
if (noteId != null) {
|
if (noteId != null) {
|
||||||
loadExistingNote(noteId)
|
loadExistingNote(noteId)
|
||||||
} else {
|
} else {
|
||||||
@@ -97,7 +100,7 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadExistingNote(noteId: String) {
|
private fun loadExistingNote(noteId: String) = viewModelScope.launch{
|
||||||
existingNote = storage.loadNote(noteId)
|
existingNote = storage.loadNote(noteId)
|
||||||
existingNote?.let { note ->
|
existingNote?.let { note ->
|
||||||
currentNoteType = note.noteType
|
currentNoteType = note.noteType
|
||||||
@@ -114,7 +117,7 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.noteType == NoteType.CHECKLIST) {
|
if (note.noteType == NoteType.CHECKLIST) {
|
||||||
loadChecklistData(note)
|
loadChecklistData(note)
|
||||||
}
|
}
|
||||||
@@ -126,7 +129,7 @@ class NoteEditorViewModel(
|
|||||||
note.checklistSortOption?.let { sortName ->
|
note.checklistSortOption?.let { sortName ->
|
||||||
_lastChecklistSortOption.value = parseSortOption(sortName)
|
_lastChecklistSortOption.value = parseSortOption(sortName)
|
||||||
}
|
}
|
||||||
|
|
||||||
val items = note.checklistItems?.sortedBy { it.order }?.map {
|
val items = note.checklistItems?.sortedBy { it.order }?.map {
|
||||||
ChecklistItemState(
|
ChecklistItemState(
|
||||||
id = it.id,
|
id = it.id,
|
||||||
@@ -146,7 +149,7 @@ class NoteEditorViewModel(
|
|||||||
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
|
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
|
||||||
NoteType.TEXT
|
NoteType.TEXT
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
noteType = currentNoteType,
|
noteType = currentNoteType,
|
||||||
@@ -158,7 +161,7 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add first empty item for new checklists
|
// Add first empty item for new checklists
|
||||||
if (currentNoteType == NoteType.CHECKLIST) {
|
if (currentNoteType == NoteType.CHECKLIST) {
|
||||||
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
|
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
|
||||||
@@ -177,19 +180,19 @@ class NoteEditorViewModel(
|
|||||||
ChecklistSortOption.MANUAL
|
ChecklistSortOption.MANUAL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Actions
|
// Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
fun updateTitle(title: String) {
|
fun updateTitle(title: String) {
|
||||||
_uiState.update { it.copy(title = title) }
|
_uiState.update { it.copy(title = title) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateContent(content: String) {
|
fun updateContent(content: String) {
|
||||||
_uiState.update { it.copy(content = content) }
|
_uiState.update { it.copy(content = content) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateChecklistItemText(itemId: String, newText: String) {
|
fun updateChecklistItemText(itemId: String, newText: String) {
|
||||||
_checklistItems.update { items ->
|
_checklistItems.update { items ->
|
||||||
items.map { item ->
|
items.map { item ->
|
||||||
@@ -197,7 +200,7 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
|
* 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
|
||||||
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten.
|
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten.
|
||||||
@@ -243,7 +246,7 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein.
|
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein.
|
||||||
*
|
*
|
||||||
@@ -320,7 +323,7 @@ class NoteEditorViewModel(
|
|||||||
else -> items.size
|
else -> items.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteChecklistItem(itemId: String) {
|
fun deleteChecklistItem(itemId: String) {
|
||||||
_checklistItems.update { items ->
|
_checklistItems.update { items ->
|
||||||
val filtered = items.filter { it.id != itemId }
|
val filtered = items.filter { it.id != itemId }
|
||||||
@@ -333,7 +336,7 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
|
fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
|
||||||
_checklistItems.update { items ->
|
_checklistItems.update { items ->
|
||||||
val fromItem = items.getOrNull(fromIndex) ?: return@update items
|
val fromItem = items.getOrNull(fromIndex) ?: return@update items
|
||||||
@@ -355,7 +358,7 @@ class NoteEditorViewModel(
|
|||||||
mutableList.mapIndexed { index, i -> i.copy(order = index) }
|
mutableList.mapIndexed { index, i -> i.copy(order = index) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔀 v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewählter Option.
|
* 🔀 v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewählter Option.
|
||||||
* Einmalige Aktion (nicht persistiert) — User kann danach per Drag & Drop feinjustieren.
|
* Einmalige Aktion (nicht persistiert) — User kann danach per Drag & Drop feinjustieren.
|
||||||
@@ -363,44 +366,44 @@ class NoteEditorViewModel(
|
|||||||
fun sortChecklistItems(option: ChecklistSortOption) {
|
fun sortChecklistItems(option: ChecklistSortOption) {
|
||||||
// Merke die Auswahl für diesen Editor-Session
|
// Merke die Auswahl für diesen Editor-Session
|
||||||
_lastChecklistSortOption.value = option
|
_lastChecklistSortOption.value = option
|
||||||
|
|
||||||
_checklistItems.update { items ->
|
_checklistItems.update { items ->
|
||||||
val sorted = when (option) {
|
val sorted = when (option) {
|
||||||
// Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird
|
// Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird
|
||||||
ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked }
|
ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked }
|
||||||
|
|
||||||
ChecklistSortOption.ALPHABETICAL_ASC ->
|
ChecklistSortOption.ALPHABETICAL_ASC ->
|
||||||
items.sortedBy { it.text.lowercase() }
|
items.sortedBy { it.text.lowercase() }
|
||||||
|
|
||||||
ChecklistSortOption.ALPHABETICAL_DESC ->
|
ChecklistSortOption.ALPHABETICAL_DESC ->
|
||||||
items.sortedByDescending { it.text.lowercase() }
|
items.sortedByDescending { it.text.lowercase() }
|
||||||
|
|
||||||
ChecklistSortOption.UNCHECKED_FIRST ->
|
ChecklistSortOption.UNCHECKED_FIRST ->
|
||||||
items.sortedBy { it.isChecked }
|
items.sortedBy { it.isChecked }
|
||||||
|
|
||||||
ChecklistSortOption.CHECKED_FIRST ->
|
ChecklistSortOption.CHECKED_FIRST ->
|
||||||
items.sortedByDescending { it.isChecked }
|
items.sortedByDescending { it.isChecked }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order-Werte neu zuweisen
|
// Order-Werte neu zuweisen
|
||||||
sorted.mapIndexed { index, item -> item.copy(order = index) }
|
sorted.mapIndexed { index, item -> item.copy(order = index) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveNote() {
|
fun saveNote() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val title = state.title.trim()
|
val title = state.title.trim()
|
||||||
|
|
||||||
when (currentNoteType) {
|
when (currentNoteType) {
|
||||||
NoteType.TEXT -> {
|
NoteType.TEXT -> {
|
||||||
val content = state.content.trim()
|
val content = state.content.trim()
|
||||||
|
|
||||||
if (title.isEmpty() && content.isEmpty()) {
|
if (title.isEmpty() && content.isEmpty()) {
|
||||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
|
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val note = if (existingNote != null) {
|
val note = if (existingNote != null) {
|
||||||
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
|
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
|
||||||
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
|
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
|
||||||
@@ -422,10 +425,10 @@ class NoteEditorViewModel(
|
|||||||
syncStatus = SyncStatus.LOCAL_ONLY
|
syncStatus = SyncStatus.LOCAL_ONLY
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.saveNote(note)
|
storage.saveNote(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
NoteType.CHECKLIST -> {
|
NoteType.CHECKLIST -> {
|
||||||
// Filter empty items
|
// Filter empty items
|
||||||
val validItems = _checklistItems.value
|
val validItems = _checklistItems.value
|
||||||
@@ -438,12 +441,12 @@ class NoteEditorViewModel(
|
|||||||
order = index
|
order = index
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (title.isEmpty() && validItems.isEmpty()) {
|
if (title.isEmpty() && validItems.isEmpty()) {
|
||||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
|
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val note = if (existingNote != null) {
|
val note = if (existingNote != null) {
|
||||||
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
|
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
|
||||||
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
|
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
|
||||||
@@ -467,11 +470,11 @@ class NoteEditorViewModel(
|
|||||||
syncStatus = SyncStatus.LOCAL_ONLY
|
syncStatus = SyncStatus.LOCAL_ONLY
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.saveNote(note)
|
storage.saveNote(note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend
|
// 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend
|
||||||
|
|
||||||
// 🌟 v1.6.0: Trigger onSave Sync
|
// 🌟 v1.6.0: Trigger onSave Sync
|
||||||
@@ -491,7 +494,7 @@ class NoteEditorViewModel(
|
|||||||
_events.emit(NoteEditorEvent.NavigateBack)
|
_events.emit(NoteEditorEvent.NavigateBack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the current note
|
* Delete the current note
|
||||||
* @param deleteOnServer if true, also triggers server deletion; if false, only deletes locally
|
* @param deleteOnServer if true, also triggers server deletion; if false, only deletes locally
|
||||||
@@ -501,10 +504,10 @@ class NoteEditorViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
existingNote?.let { note ->
|
existingNote?.let { note ->
|
||||||
val noteId = note.id
|
val noteId = note.id
|
||||||
|
|
||||||
// Delete locally first
|
// Delete locally first
|
||||||
storage.deleteNote(noteId)
|
storage.deleteNote(noteId)
|
||||||
|
|
||||||
// Delete from server if requested
|
// Delete from server if requested
|
||||||
if (deleteOnServer) {
|
if (deleteOnServer) {
|
||||||
try {
|
try {
|
||||||
@@ -538,18 +541,18 @@ class NoteEditorViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_events.emit(NoteEditorEvent.NavigateBack)
|
_events.emit(NoteEditorEvent.NavigateBack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDeleteConfirmation() {
|
fun showDeleteConfirmation() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_events.emit(NoteEditorEvent.ShowDeleteConfirmation)
|
_events.emit(NoteEditorEvent.ShowDeleteConfirmation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun canDelete(): Boolean = existingNote != null
|
fun canDelete(): Boolean = existingNote != null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -564,10 +567,10 @@ class NoteEditorViewModel(
|
|||||||
* Nur checklistItems werden aktualisiert — nicht title oder content,
|
* Nur checklistItems werden aktualisiert — nicht title oder content,
|
||||||
* damit ungespeicherte Text-Änderungen im Editor nicht verloren gehen.
|
* damit ungespeicherte Text-Änderungen im Editor nicht verloren gehen.
|
||||||
*/
|
*/
|
||||||
fun reloadFromStorage() {
|
fun reloadFromStorage() = viewModelScope.launch{
|
||||||
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID) ?: return
|
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
|
// Nur Checklist-Items aktualisieren
|
||||||
if (freshNote.noteType == NoteType.CHECKLIST) {
|
if (freshNote.noteType == NoteType.CHECKLIST) {
|
||||||
@@ -578,7 +581,7 @@ class NoteEditorViewModel(
|
|||||||
isChecked = it.isChecked,
|
isChecked = it.isChecked,
|
||||||
order = it.order
|
order = it.order
|
||||||
)
|
)
|
||||||
} ?: return
|
} ?: return@launch
|
||||||
|
|
||||||
_checklistItems.value = sortChecklistItems(freshItems)
|
_checklistItems.value = sortChecklistItems(freshItems)
|
||||||
// existingNote aktualisieren damit beim Speichern der richtige
|
// existingNote aktualisieren damit beim Speichern der richtige
|
||||||
@@ -586,16 +589,16 @@ class NoteEditorViewModel(
|
|||||||
existingNote = freshNote
|
existingNote = freshNote
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// 🌟 v1.6.0: Sync Trigger - onSave
|
// 🌟 v1.6.0: Sync Trigger - onSave
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers sync after saving a note (if enabled and server configured)
|
* Triggers sync after saving a note (if enabled and server configured)
|
||||||
* v1.6.0: New configurable sync trigger
|
* v1.6.0: New configurable sync trigger
|
||||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||||
*
|
*
|
||||||
* Separate throttling (5 seconds) to prevent spam when saving multiple times
|
* Separate throttling (5 seconds) to prevent spam when saving multiple times
|
||||||
*/
|
*/
|
||||||
private fun triggerOnSaveSync() {
|
private fun triggerOnSaveSync() {
|
||||||
@@ -604,7 +607,7 @@ class NoteEditorViewModel(
|
|||||||
Logger.d(TAG, "⏭️ onSave sync disabled - skipping")
|
Logger.d(TAG, "⏭️ onSave sync disabled - skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||||
val syncService = WebDavSyncService(getApplication())
|
val syncService = WebDavSyncService(getApplication())
|
||||||
val gateResult = syncService.canSync()
|
val gateResult = syncService.canSync()
|
||||||
@@ -616,21 +619,21 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 2: Throttling (5 seconds) to prevent spam
|
// Check 2: Throttling (5 seconds) to prevent spam
|
||||||
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
|
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val timeSinceLastSync = now - lastOnSaveSyncTime
|
val timeSinceLastSync = now - lastOnSaveSyncTime
|
||||||
|
|
||||||
if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) {
|
if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) {
|
||||||
val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||||
Logger.d(TAG, "⏳ onSave sync throttled - wait ${remainingSeconds}s")
|
Logger.d(TAG, "⏳ onSave sync throttled - wait ${remainingSeconds}s")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply()
|
prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply()
|
||||||
|
|
||||||
// Trigger sync via WorkManager
|
// Trigger sync via WorkManager
|
||||||
Logger.d(TAG, "📤 Triggering onSave sync")
|
Logger.d(TAG, "📤 Triggering onSave sync")
|
||||||
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -44,11 +45,14 @@ import dev.dettmer.simplenotes.utils.Constants
|
|||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
|
import kotlin.getValue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main Activity with Jetpack Compose UI
|
* Main Activity with Jetpack Compose UI
|
||||||
* v1.5.0: Complete MainActivity Redesign with Compose
|
* v1.5.0: Complete MainActivity Redesign with Compose
|
||||||
*
|
*
|
||||||
* Replaces the old 805-line MainActivity.kt with a modern
|
* Replaces the old 805-line MainActivity.kt with a modern
|
||||||
* Compose-based implementation featuring:
|
* Compose-based implementation featuring:
|
||||||
* - Notes list with swipe-to-delete
|
* - Notes list with swipe-to-delete
|
||||||
@@ -58,22 +62,21 @@ import kotlinx.coroutines.launch
|
|||||||
* - Design consistent with ComposeSettingsActivity
|
* - Design consistent with ComposeSettingsActivity
|
||||||
*/
|
*/
|
||||||
class ComposeMainActivity : ComponentActivity() {
|
class ComposeMainActivity : ComponentActivity() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ComposeMainActivity"
|
private const val TAG = "ComposeMainActivity"
|
||||||
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
||||||
private const val REQUEST_SETTINGS = 1002
|
private const val REQUEST_SETTINGS = 1002
|
||||||
}
|
}
|
||||||
|
|
||||||
private val viewModel: MainViewModel by viewModels()
|
private val viewModel: MainViewModel by viewModel()
|
||||||
|
|
||||||
private val prefs by lazy {
|
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: Track if coming from editor to scroll to top
|
// Phase 3: Track if coming from editor to scroll to top
|
||||||
private var cameFromEditor = false
|
private var cameFromEditor = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BroadcastReceiver for Background-Sync Completion (Periodic Sync)
|
* BroadcastReceiver for Background-Sync Completion (Periodic Sync)
|
||||||
*/
|
*/
|
||||||
@@ -81,9 +84,9 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
val success = intent?.getBooleanExtra("success", false) ?: false
|
val success = intent?.getBooleanExtra("success", false) ?: false
|
||||||
val count = intent?.getIntExtra("count", 0) ?: 0
|
val count = intent?.getIntExtra("count", 0) ?: 0
|
||||||
|
|
||||||
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
||||||
|
|
||||||
// UI refresh
|
// UI refresh
|
||||||
if (success && count > 0) {
|
if (success && count > 0) {
|
||||||
viewModel.loadNotes()
|
viewModel.loadNotes()
|
||||||
@@ -91,60 +94,60 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// Install Splash Screen (Android 12+)
|
// Install Splash Screen (Android 12+)
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// Apply Dynamic Colors for Material You (Android 12+)
|
// Apply Dynamic Colors for Material You (Android 12+)
|
||||||
DynamicColors.applyToActivityIfAvailable(this)
|
DynamicColors.applyToActivityIfAvailable(this)
|
||||||
|
|
||||||
// Enable edge-to-edge display
|
// Enable edge-to-edge display
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
// Initialize Logger and enable file logging if configured
|
// Initialize Logger and enable file logging if configured
|
||||||
Logger.init(this)
|
Logger.init(this)
|
||||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||||
Logger.setFileLoggingEnabled(true)
|
Logger.setFileLoggingEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear old sync notifications on app start
|
// Clear old sync notifications on app start
|
||||||
NotificationHelper.clearSyncNotifications(this)
|
NotificationHelper.clearSyncNotifications(this)
|
||||||
|
|
||||||
// Request notification permission (Android 13+)
|
// Request notification permission (Android 13+)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.4.1: Migrate checklists for backwards compatibility
|
// v1.4.1: Migrate checklists for backwards compatibility
|
||||||
migrateChecklistsForBackwardsCompat()
|
migrateChecklistsForBackwardsCompat()
|
||||||
|
|
||||||
// Setup Sync State Observer
|
// Setup Sync State Observer
|
||||||
setupSyncStateObserver()
|
setupSyncStateObserver()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SimpleNotesTheme {
|
SimpleNotesTheme {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
// Dialog state for delete confirmation
|
// Dialog state for delete confirmation
|
||||||
var deleteDialogData by remember { mutableStateOf<MainViewModel.DeleteDialogData?>(null) }
|
var deleteDialogData by remember { mutableStateOf<MainViewModel.DeleteDialogData?>(null) }
|
||||||
|
|
||||||
// Handle delete dialog events
|
// Handle delete dialog events
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.showDeleteDialog.collect { data ->
|
viewModel.showDeleteDialog.collect { data ->
|
||||||
deleteDialogData = data
|
deleteDialogData = data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle toast events
|
// Handle toast events
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.showToast.collect { message ->
|
viewModel.showToast.collect { message ->
|
||||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
deleteDialogData?.let { data ->
|
deleteDialogData?.let { data ->
|
||||||
DeleteConfirmationDialog(
|
DeleteConfirmationDialog(
|
||||||
@@ -163,70 +166,70 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
MainScreen(
|
MainScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onOpenNote = { noteId -> openNoteEditor(noteId) },
|
onOpenNote = { noteId -> openNoteEditor(noteId) },
|
||||||
onOpenSettings = { openSettings() },
|
onOpenSettings = { openSettings() },
|
||||||
onCreateNote = { noteType -> createNote(noteType) }
|
onCreateNote = { noteType -> createNote(noteType) }
|
||||||
)
|
)
|
||||||
|
|
||||||
// v1.8.0: Post-Update Changelog (shows once after update)
|
// v1.8.0: Post-Update Changelog (shows once after update)
|
||||||
UpdateChangelogSheet()
|
UpdateChangelogSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
|
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
|
||||||
|
|
||||||
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
|
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
|
||||||
// This ensures UI reflects current offline mode when returning from Settings
|
// This ensures UI reflects current offline mode when returning from Settings
|
||||||
viewModel.refreshOfflineModeState()
|
viewModel.refreshOfflineModeState()
|
||||||
|
|
||||||
// 🎨 v1.7.0: Refresh display mode when returning from Settings
|
// 🎨 v1.7.0: Refresh display mode when returning from Settings
|
||||||
viewModel.refreshDisplayMode()
|
viewModel.refreshDisplayMode()
|
||||||
|
|
||||||
// Register BroadcastReceiver for Background-Sync
|
// Register BroadcastReceiver for Background-Sync
|
||||||
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
|
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
|
||||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||||
syncCompletedReceiver,
|
syncCompletedReceiver,
|
||||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||||
)
|
)
|
||||||
|
|
||||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
||||||
|
|
||||||
// Reload notes
|
// Reload notes
|
||||||
viewModel.loadNotes()
|
viewModel.loadNotes()
|
||||||
|
|
||||||
// Phase 3: Scroll to top if coming from editor (new/edited note)
|
// Phase 3: Scroll to top if coming from editor (new/edited note)
|
||||||
if (cameFromEditor) {
|
if (cameFromEditor) {
|
||||||
viewModel.scrollToTop()
|
viewModel.scrollToTop()
|
||||||
cameFromEditor = false
|
cameFromEditor = false
|
||||||
Logger.d(TAG, "📜 Came from editor - scrolling to top")
|
Logger.d(TAG, "📜 Came from editor - scrolling to top")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger Auto-Sync on app resume
|
// Trigger Auto-Sync on app resume
|
||||||
viewModel.triggerAutoSync("onResume")
|
viewModel.triggerAutoSync("onResume")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
|
|
||||||
// Unregister BroadcastReceiver
|
// Unregister BroadcastReceiver
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
||||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupSyncStateObserver() {
|
private fun setupSyncStateObserver() {
|
||||||
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
|
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
|
||||||
SyncStateManager.syncStatus.observe(this) { status ->
|
SyncStateManager.syncStatus.observe(this) { status ->
|
||||||
viewModel.updateSyncState(status)
|
viewModel.updateSyncState(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
|
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
SyncStateManager.syncProgress.collect { progress ->
|
SyncStateManager.syncProgress.collect { progress ->
|
||||||
@@ -250,14 +253,14 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openNoteEditor(noteId: String?) {
|
private fun openNoteEditor(noteId: String?) {
|
||||||
cameFromEditor = true
|
cameFromEditor = true
|
||||||
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||||
noteId?.let {
|
noteId?.let {
|
||||||
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
|
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.5.0: Add slide animation
|
// v1.5.0: Add slide animation
|
||||||
val options = ActivityOptions.makeCustomAnimation(
|
val options = ActivityOptions.makeCustomAnimation(
|
||||||
this,
|
this,
|
||||||
@@ -266,12 +269,12 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
startActivity(intent, options.toBundle())
|
startActivity(intent, options.toBundle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNote(noteType: NoteType) {
|
private fun createNote(noteType: NoteType) {
|
||||||
cameFromEditor = true
|
cameFromEditor = true
|
||||||
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||||
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||||
|
|
||||||
// v1.5.0: Add slide animation
|
// v1.5.0: Add slide animation
|
||||||
val options = ActivityOptions.makeCustomAnimation(
|
val options = ActivityOptions.makeCustomAnimation(
|
||||||
this,
|
this,
|
||||||
@@ -280,7 +283,7 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
startActivity(intent, options.toBundle())
|
startActivity(intent, options.toBundle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openSettings() {
|
private fun openSettings() {
|
||||||
val intent = Intent(this, ComposeSettingsActivity::class.java)
|
val intent = Intent(this, ComposeSettingsActivity::class.java)
|
||||||
val options = ActivityOptions.makeCustomAnimation(
|
val options = ActivityOptions.makeCustomAnimation(
|
||||||
@@ -291,10 +294,10 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
|
startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestNotificationPermission() {
|
private fun requestNotificationPermission() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
requestPermissions(
|
requestPermissions(
|
||||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
@@ -303,54 +306,25 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v1.4.1: Migrates existing checklists for backwards compatibility.
|
* v1.4.1: Migrates existing checklists for backwards compatibility.
|
||||||
*/
|
*/
|
||||||
private fun migrateChecklistsForBackwardsCompat() {
|
private fun migrateChecklistsForBackwardsCompat() {
|
||||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
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")
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
|
||||||
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
||||||
// Settings changed, reload notes
|
// Settings changed, reload notes
|
||||||
viewModel.loadNotes()
|
viewModel.loadNotes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
|
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
|
||||||
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||||
override fun onRequestPermissionsResult(
|
override fun onRequestPermissionsResult(
|
||||||
@@ -359,15 +333,15 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
grantResults: IntArray
|
grantResults: IntArray
|
||||||
) {
|
) {
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
|
||||||
when (requestCode) {
|
when (requestCode) {
|
||||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||||
if (grantResults.isNotEmpty() &&
|
if (grantResults.isNotEmpty() &&
|
||||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this,
|
Toast.makeText(this,
|
||||||
getString(R.string.toast_notifications_disabled),
|
getString(R.string.toast_notifications_disabled),
|
||||||
Toast.LENGTH_SHORT
|
Toast.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
@@ -389,8 +363,8 @@ private fun DeleteConfirmationDialog(
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
|
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
|
||||||
text = {
|
text = {
|
||||||
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
|
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
|
|||||||
@@ -59,13 +59,14 @@ import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
|
|||||||
import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner
|
import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner
|
||||||
import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
|
import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
|
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main screen displaying the notes list
|
* Main screen displaying the notes list
|
||||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||||
*
|
*
|
||||||
* Performance optimized with proper state handling:
|
* Performance optimized with proper state handling:
|
||||||
* - LazyListState for scroll control
|
* - LazyListState for scroll control
|
||||||
* - Scaffold FAB slot for proper z-ordering
|
* - Scaffold FAB slot for proper z-ordering
|
||||||
@@ -74,7 +75,7 @@ private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
viewModel: MainViewModel,
|
viewModel: MainViewModel = koinViewModel(),
|
||||||
onOpenNote: (String?) -> Unit,
|
onOpenNote: (String?) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onCreateNote: (NoteType) -> Unit
|
onCreateNote: (NoteType) -> Unit
|
||||||
@@ -82,37 +83,37 @@ fun MainScreen(
|
|||||||
val notes by viewModel.sortedNotes.collectAsState()
|
val notes by viewModel.sortedNotes.collectAsState()
|
||||||
val syncState by viewModel.syncState.collectAsState()
|
val syncState by viewModel.syncState.collectAsState()
|
||||||
val scrollToTop by viewModel.scrollToTop.collectAsState()
|
val scrollToTop by viewModel.scrollToTop.collectAsState()
|
||||||
|
|
||||||
// 🆕 v1.8.0: Einziges Banner-System
|
// 🆕 v1.8.0: Einziges Banner-System
|
||||||
val syncProgress by viewModel.syncProgress.collectAsState()
|
val syncProgress by viewModel.syncProgress.collectAsState()
|
||||||
|
|
||||||
// Multi-Select State
|
// Multi-Select State
|
||||||
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
||||||
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
||||||
|
|
||||||
// 🌟 v1.6.0: Reactive offline mode state
|
// 🌟 v1.6.0: Reactive offline mode state
|
||||||
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||||
|
|
||||||
// 🎨 v1.7.0: Display mode (list or grid)
|
// 🎨 v1.7.0: Display mode (list or grid)
|
||||||
val displayMode by viewModel.displayMode.collectAsState()
|
val displayMode by viewModel.displayMode.collectAsState()
|
||||||
|
|
||||||
// Delete confirmation dialog state
|
// Delete confirmation dialog state
|
||||||
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 🆕 v1.8.0: Sync status legend dialog
|
// 🆕 v1.8.0: Sync status legend dialog
|
||||||
var showSyncLegend by remember { mutableStateOf(false) }
|
var showSyncLegend by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 🔀 v1.8.0: Sort dialog state
|
// 🔀 v1.8.0: Sort dialog state
|
||||||
var showSortDialog by remember { mutableStateOf(false) }
|
var showSortDialog by remember { mutableStateOf(false) }
|
||||||
val sortOption by viewModel.sortOption.collectAsState()
|
val sortOption by viewModel.sortOption.collectAsState()
|
||||||
val sortDirection by viewModel.sortDirection.collectAsState()
|
val sortDirection by viewModel.sortDirection.collectAsState()
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
||||||
val gridState = rememberLazyStaggeredGridState()
|
val gridState = rememberLazyStaggeredGridState()
|
||||||
|
|
||||||
// ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times
|
// ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times
|
||||||
var timestampTicker by remember { mutableStateOf(0L) }
|
var timestampTicker by remember { mutableStateOf(0L) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -121,17 +122,17 @@ fun MainScreen(
|
|||||||
timestampTicker = System.currentTimeMillis()
|
timestampTicker = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute isSyncing once
|
// Compute isSyncing once
|
||||||
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
||||||
|
|
||||||
// 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes)
|
// 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes)
|
||||||
// Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
|
// Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
|
||||||
// which is called in ComposeMainActivity.onResume() when returning from Settings
|
// which is called in ComposeMainActivity.onResume() when returning from Settings
|
||||||
val hasServerConfig = viewModel.hasServerConfig()
|
val hasServerConfig = viewModel.hasServerConfig()
|
||||||
val isSyncAvailable = !isOfflineMode && hasServerConfig
|
val isSyncAvailable = !isOfflineMode && hasServerConfig
|
||||||
val canSync = isSyncAvailable && !isSyncing
|
val canSync = isSyncAvailable && !isSyncing
|
||||||
|
|
||||||
// Handle snackbar events from ViewModel
|
// Handle snackbar events from ViewModel
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.showSnackbar.collect { data ->
|
viewModel.showSnackbar.collect { data ->
|
||||||
@@ -147,7 +148,7 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Scroll to top when new note created
|
// Phase 3: Scroll to top when new note created
|
||||||
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
|
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
|
||||||
LaunchedEffect(scrollToTop) {
|
LaunchedEffect(scrollToTop) {
|
||||||
@@ -160,7 +161,7 @@ fun MainScreen(
|
|||||||
viewModel.resetScrollToTop()
|
viewModel.resetScrollToTop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit
|
// v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -213,7 +214,7 @@ fun MainScreen(
|
|||||||
progress = syncProgress,
|
progress = syncProgress,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Content: Empty state or notes list
|
// Content: Empty state or notes list
|
||||||
if (notes.isEmpty()) {
|
if (notes.isEmpty()) {
|
||||||
EmptyState(modifier = Modifier.weight(1f))
|
EmptyState(modifier = Modifier.weight(1f))
|
||||||
@@ -249,7 +250,7 @@ fun MainScreen(
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onNoteClick = { note -> onOpenNote(note.id) },
|
onNoteClick = { note -> onOpenNote(note.id) },
|
||||||
onNoteLongPress = { note ->
|
onNoteLongPress = { note ->
|
||||||
// Long-press starts selection mode
|
// Long-press starts selection mode
|
||||||
viewModel.startSelectionMode(note.id)
|
viewModel.startSelectionMode(note.id)
|
||||||
},
|
},
|
||||||
@@ -260,7 +261,7 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
|
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !isSelectionMode,
|
visible = !isSelectionMode,
|
||||||
@@ -277,7 +278,7 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch Delete Confirmation Dialog
|
// Batch Delete Confirmation Dialog
|
||||||
if (showBatchDeleteDialog) {
|
if (showBatchDeleteDialog) {
|
||||||
DeleteConfirmationDialog(
|
DeleteConfirmationDialog(
|
||||||
@@ -294,14 +295,14 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.8.0: Sync Status Legend Dialog
|
// 🆕 v1.8.0: Sync Status Legend Dialog
|
||||||
if (showSyncLegend) {
|
if (showSyncLegend) {
|
||||||
SyncStatusLegendDialog(
|
SyncStatusLegendDialog(
|
||||||
onDismiss = { showSyncLegend = false }
|
onDismiss = { showSyncLegend = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔀 v1.8.0: Sort Dialog
|
// 🔀 v1.8.0: Sort Dialog
|
||||||
if (showSortDialog) {
|
if (showSortDialog) {
|
||||||
SortDialog(
|
SortDialog(
|
||||||
@@ -344,7 +345,7 @@ private fun MainTopBar(
|
|||||||
contentDescription = stringResource(R.string.sort_notes)
|
contentDescription = stringResource(R.string.sort_notes)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar)
|
// 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar)
|
||||||
if (showSyncLegend) {
|
if (showSyncLegend) {
|
||||||
IconButton(onClick = onSyncLegendClick) {
|
IconButton(onClick = onSyncLegendClick) {
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package dev.dettmer.simplenotes.ui.main
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.models.SortDirection
|
import dev.dettmer.simplenotes.models.SortDirection
|
||||||
import dev.dettmer.simplenotes.models.SortOption
|
import dev.dettmer.simplenotes.models.SortOption
|
||||||
import dev.dettmer.simplenotes.R
|
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.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.sync.SyncProgress
|
import dev.dettmer.simplenotes.sync.SyncProgress
|
||||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||||
@@ -27,54 +30,56 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
|
import kotlin.getValue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ViewModel for MainActivity Compose
|
* ViewModel for MainActivity Compose
|
||||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||||
*
|
*
|
||||||
* Manages notes list, sync state, and deletion with undo.
|
* Manages notes list, sync state, and deletion with undo.
|
||||||
*/
|
*/
|
||||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MainViewModel"
|
private const val TAG = "MainViewModel"
|
||||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val storage = NotesStorage(application)
|
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Notes State
|
// Notes State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _notes = MutableStateFlow<List<Note>>(emptyList())
|
private val _notes = MutableStateFlow<List<Note>>(emptyList())
|
||||||
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
||||||
|
|
||||||
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
|
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
|
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Multi-Select State (v1.5.0)
|
// Multi-Select State (v1.5.0)
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
|
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
|
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
|
||||||
|
|
||||||
val isSelectionMode: StateFlow<Boolean> = _selectedNotes
|
val isSelectionMode: StateFlow<Boolean> = _selectedNotes
|
||||||
.map { it.isNotEmpty() }
|
.map { it.isNotEmpty() }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// 🌟 v1.6.0: Offline Mode State (reactive)
|
// 🌟 v1.6.0: Offline Mode State (reactive)
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _isOfflineMode = MutableStateFlow(
|
private val _isOfflineMode = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||||
)
|
)
|
||||||
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh offline mode state from SharedPreferences
|
* Refresh offline mode state from SharedPreferences
|
||||||
* Called when returning from Settings screen (in onResume)
|
* Called when returning from Settings screen (in onResume)
|
||||||
@@ -85,16 +90,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_isOfflineMode.value = newValue
|
_isOfflineMode.value = newValue
|
||||||
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue")
|
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// 🎨 v1.7.0: Display Mode State
|
// 🎨 v1.7.0: Display Mode State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _displayMode = MutableStateFlow(
|
private val _displayMode = MutableStateFlow(
|
||||||
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||||
)
|
)
|
||||||
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh display mode from SharedPreferences
|
* Refresh display mode from SharedPreferences
|
||||||
* Called when returning from Settings screen
|
* Called when returning from Settings screen
|
||||||
@@ -104,25 +109,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_displayMode.value = newValue
|
_displayMode.value = newValue
|
||||||
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value} → $newValue")
|
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value} → $newValue")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// 🔀 v1.8.0: Sort State
|
// 🔀 v1.8.0: Sort State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _sortOption = MutableStateFlow(
|
private val _sortOption = MutableStateFlow(
|
||||||
SortOption.fromPrefsValue(
|
SortOption.fromPrefsValue(
|
||||||
prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION
|
prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val sortOption: StateFlow<SortOption> = _sortOption.asStateFlow()
|
val sortOption: StateFlow<SortOption> = _sortOption.asStateFlow()
|
||||||
|
|
||||||
private val _sortDirection = MutableStateFlow(
|
private val _sortDirection = MutableStateFlow(
|
||||||
SortDirection.fromPrefsValue(
|
SortDirection.fromPrefsValue(
|
||||||
prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION
|
prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val sortDirection: StateFlow<SortDirection> = _sortDirection.asStateFlow()
|
val sortDirection: StateFlow<SortDirection> = _sortDirection.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection.
|
* 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection.
|
||||||
* Reagiert automatisch auf Änderungen in allen drei Flows.
|
* Reagiert automatisch auf Änderungen in allen drei Flows.
|
||||||
@@ -138,68 +143,68 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
started = SharingStarted.WhileSubscribed(5_000),
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
initialValue = emptyList()
|
initialValue = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Sync State
|
// Sync State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
|
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
|
||||||
val syncProgress: StateFlow<SyncProgress> = SyncStateManager.syncProgress
|
val syncProgress: StateFlow<SyncProgress> = SyncStateManager.syncProgress
|
||||||
|
|
||||||
// Intern: SyncState für PullToRefresh-Indikator
|
// Intern: SyncState für PullToRefresh-Indikator
|
||||||
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
|
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
|
||||||
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
|
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// UI Events
|
// UI Events
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _showToast = MutableSharedFlow<String>()
|
private val _showToast = MutableSharedFlow<String>()
|
||||||
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
||||||
|
|
||||||
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
|
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
|
||||||
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
|
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
|
||||||
|
|
||||||
private val _showSnackbar = MutableSharedFlow<SnackbarData>()
|
private val _showSnackbar = MutableSharedFlow<SnackbarData>()
|
||||||
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
|
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
|
||||||
|
|
||||||
// Phase 3: Scroll-to-top when new note is created
|
// Phase 3: Scroll-to-top when new note is created
|
||||||
private val _scrollToTop = MutableStateFlow(false)
|
private val _scrollToTop = MutableStateFlow(false)
|
||||||
val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow()
|
val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow()
|
||||||
|
|
||||||
// Track first note ID to detect new notes
|
// Track first note ID to detect new notes
|
||||||
private var previousFirstNoteId: String? = null
|
private var previousFirstNoteId: String? = null
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Data Classes
|
// Data Classes
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
data class DeleteDialogData(
|
data class DeleteDialogData(
|
||||||
val note: Note,
|
val note: Note,
|
||||||
val originalList: List<Note>
|
val originalList: List<Note>
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SnackbarData(
|
data class SnackbarData(
|
||||||
val message: String,
|
val message: String,
|
||||||
val actionLabel: String,
|
val actionLabel: String,
|
||||||
val onAction: () -> Unit
|
val onAction: () -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Initialization
|
// Initialization
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
|
// v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
loadNotesAsync()
|
loadNotesAsync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Notes Actions
|
// Notes Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load notes asynchronously on IO dispatcher
|
* Load notes asynchronously on IO dispatcher
|
||||||
* This prevents UI blocking during app startup
|
* This prevents UI blocking during app startup
|
||||||
@@ -208,23 +213,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val allNotes = storage.loadAllNotes()
|
val allNotes = storage.loadAllNotes()
|
||||||
val pendingIds = _pendingDeletions.value
|
val pendingIds = _pendingDeletions.value
|
||||||
val filteredNotes = allNotes.filter { it.id !in pendingIds }
|
val filteredNotes = allNotes.filter { it.id !in pendingIds }
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
// Phase 3: Detect if a new note was added at the top
|
// Phase 3: Detect if a new note was added at the top
|
||||||
val newFirstNoteId = filteredNotes.firstOrNull()?.id
|
val newFirstNoteId = filteredNotes.firstOrNull()?.id
|
||||||
if (newFirstNoteId != null &&
|
if (newFirstNoteId != null &&
|
||||||
previousFirstNoteId != null &&
|
previousFirstNoteId != null &&
|
||||||
newFirstNoteId != previousFirstNoteId) {
|
newFirstNoteId != previousFirstNoteId) {
|
||||||
// New note at top → trigger scroll
|
// New note at top → trigger scroll
|
||||||
_scrollToTop.value = true
|
_scrollToTop.value = true
|
||||||
Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top")
|
Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top")
|
||||||
}
|
}
|
||||||
previousFirstNoteId = newFirstNoteId
|
previousFirstNoteId = newFirstNoteId
|
||||||
|
|
||||||
_notes.value = filteredNotes
|
_notes.value = filteredNotes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public loadNotes - delegates to async version
|
* Public loadNotes - delegates to async version
|
||||||
*/
|
*/
|
||||||
@@ -233,25 +238,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
loadNotesAsync()
|
loadNotesAsync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset scroll-to-top flag after scroll completed
|
* Reset scroll-to-top flag after scroll completed
|
||||||
*/
|
*/
|
||||||
fun resetScrollToTop() {
|
fun resetScrollToTop() {
|
||||||
_scrollToTop.value = false
|
_scrollToTop.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force scroll to top (e.g., after returning from editor)
|
* Force scroll to top (e.g., after returning from editor)
|
||||||
*/
|
*/
|
||||||
fun scrollToTop() {
|
fun scrollToTop() {
|
||||||
_scrollToTop.value = true
|
_scrollToTop.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Multi-Select Actions (v1.5.0)
|
// Multi-Select Actions (v1.5.0)
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle selection of a note
|
* Toggle selection of a note
|
||||||
*/
|
*/
|
||||||
@@ -262,56 +267,56 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_selectedNotes.value + noteId
|
_selectedNotes.value + noteId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start selection mode with initial note
|
* Start selection mode with initial note
|
||||||
*/
|
*/
|
||||||
fun startSelectionMode(noteId: String) {
|
fun startSelectionMode(noteId: String) {
|
||||||
_selectedNotes.value = setOf(noteId)
|
_selectedNotes.value = setOf(noteId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select all notes
|
* Select all notes
|
||||||
*/
|
*/
|
||||||
fun selectAllNotes() {
|
fun selectAllNotes() {
|
||||||
_selectedNotes.value = _notes.value.map { it.id }.toSet()
|
_selectedNotes.value = _notes.value.map { it.id }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear selection and exit selection mode
|
* Clear selection and exit selection mode
|
||||||
*/
|
*/
|
||||||
fun clearSelection() {
|
fun clearSelection() {
|
||||||
_selectedNotes.value = emptySet()
|
_selectedNotes.value = emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get count of selected notes
|
* Get count of selected notes
|
||||||
*/
|
*/
|
||||||
fun getSelectedCount(): Int = _selectedNotes.value.size
|
fun getSelectedCount(): Int = _selectedNotes.value.size
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all selected notes
|
* Delete all selected notes
|
||||||
*/
|
*/
|
||||||
fun deleteSelectedNotes(deleteFromServer: Boolean) {
|
fun deleteSelectedNotes(deleteFromServer: Boolean) = viewModelScope.launch {
|
||||||
val selectedIds = _selectedNotes.value.toList()
|
val selectedIds = _selectedNotes.value.toList()
|
||||||
val selectedNotes = _notes.value.filter { it.id in selectedIds }
|
val selectedNotes = _notes.value.filter { it.id in selectedIds }
|
||||||
|
|
||||||
if (selectedNotes.isEmpty()) return
|
if (selectedNotes.isEmpty()) return@launch
|
||||||
|
|
||||||
// Add to pending deletions
|
// Add to pending deletions
|
||||||
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
|
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
|
||||||
|
|
||||||
// Delete from storage
|
// Delete from storage
|
||||||
selectedNotes.forEach { note ->
|
selectedNotes.forEach { note ->
|
||||||
storage.deleteNote(note.id)
|
storage.deleteNote(note.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear selection
|
// Clear selection
|
||||||
clearSelection()
|
clearSelection()
|
||||||
|
|
||||||
// Reload notes
|
// Reload notes
|
||||||
loadNotes()
|
loadNotes()
|
||||||
|
|
||||||
// Show snackbar with undo
|
// Show snackbar with undo
|
||||||
val count = selectedNotes.size
|
val count = selectedNotes.size
|
||||||
val message = if (deleteFromServer) {
|
val message = if (deleteFromServer) {
|
||||||
@@ -319,7 +324,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} else {
|
} else {
|
||||||
getString(R.string.snackbar_notes_deleted_local, count)
|
getString(R.string.snackbar_notes_deleted_local, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_showSnackbar.emit(SnackbarData(
|
_showSnackbar.emit(SnackbarData(
|
||||||
message = message,
|
message = message,
|
||||||
@@ -328,7 +333,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
undoDeleteMultiple(selectedNotes)
|
undoDeleteMultiple(selectedNotes)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
@Suppress("MagicNumber") // Snackbar timing coordination
|
@Suppress("MagicNumber") // Snackbar timing coordination
|
||||||
// If delete from server, actually delete after a short delay
|
// If delete from server, actually delete after a short delay
|
||||||
// (to allow undo action before server deletion)
|
// (to allow undo action before server deletion)
|
||||||
@@ -347,19 +352,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Undo deletion of multiple notes
|
* Undo deletion of multiple notes
|
||||||
*/
|
*/
|
||||||
private fun undoDeleteMultiple(notes: List<Note>) {
|
private fun undoDeleteMultiple(notes: List<Note>) = viewModelScope.launch{
|
||||||
// Remove from pending deletions
|
// Remove from pending deletions
|
||||||
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
|
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
|
||||||
|
|
||||||
// Restore to storage
|
// Restore to storage
|
||||||
notes.forEach { note ->
|
notes.forEach { note ->
|
||||||
storage.saveNote(note)
|
storage.saveNote(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload notes
|
// Reload notes
|
||||||
loadNotes()
|
loadNotes()
|
||||||
}
|
}
|
||||||
@@ -370,10 +375,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
*/
|
*/
|
||||||
fun onNoteLongPressDelete(note: Note) {
|
fun onNoteLongPressDelete(note: Note) {
|
||||||
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
||||||
|
|
||||||
// Store original list for potential restore
|
// Store original list for potential restore
|
||||||
val originalList = _notes.value.toList()
|
val originalList = _notes.value.toList()
|
||||||
|
|
||||||
if (alwaysDeleteFromServer) {
|
if (alwaysDeleteFromServer) {
|
||||||
// Auto-delete without dialog
|
// Auto-delete without dialog
|
||||||
deleteNoteConfirmed(note, deleteFromServer = true)
|
deleteNoteConfirmed(note, deleteFromServer = true)
|
||||||
@@ -392,34 +397,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
fun onNoteSwipedToDelete(note: Note) {
|
fun onNoteSwipedToDelete(note: Note) {
|
||||||
onNoteLongPressDelete(note) // Delegate to long-press handler
|
onNoteLongPressDelete(note) // Delegate to long-press handler
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore note after swipe (user cancelled dialog)
|
* Restore note after swipe (user cancelled dialog)
|
||||||
*/
|
*/
|
||||||
fun restoreNoteAfterSwipe(originalList: List<Note>) {
|
fun restoreNoteAfterSwipe(originalList: List<Note>) {
|
||||||
_notes.value = originalList
|
_notes.value = originalList
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm note deletion (from dialog or auto-delete)
|
* Confirm note deletion (from dialog or auto-delete)
|
||||||
*/
|
*/
|
||||||
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
|
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) = viewModelScope.launch{
|
||||||
// Add to pending deletions
|
// Add to pending deletions
|
||||||
_pendingDeletions.value = _pendingDeletions.value + note.id
|
_pendingDeletions.value = _pendingDeletions.value + note.id
|
||||||
|
|
||||||
// Delete from storage
|
// Delete from storage
|
||||||
storage.deleteNote(note.id)
|
storage.deleteNote(note.id)
|
||||||
|
|
||||||
// Reload notes
|
// Reload notes
|
||||||
loadNotes()
|
loadNotes()
|
||||||
|
|
||||||
// Show snackbar with undo
|
// Show snackbar with undo
|
||||||
val message = if (deleteFromServer) {
|
val message = if (deleteFromServer) {
|
||||||
getString(R.string.snackbar_note_deleted_server, note.title)
|
getString(R.string.snackbar_note_deleted_server, note.title)
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.snackbar_note_deleted_local, note.title)
|
getString(R.string.snackbar_note_deleted_local, note.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_showSnackbar.emit(SnackbarData(
|
_showSnackbar.emit(SnackbarData(
|
||||||
message = message,
|
message = message,
|
||||||
@@ -428,7 +433,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
undoDelete(note)
|
undoDelete(note)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
@Suppress("MagicNumber") // Snackbar timing
|
@Suppress("MagicNumber") // Snackbar timing
|
||||||
// If delete from server, actually delete after snackbar timeout
|
// If delete from server, actually delete after snackbar timeout
|
||||||
if (deleteFromServer) {
|
if (deleteFromServer) {
|
||||||
@@ -443,21 +448,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Undo note deletion
|
* Undo note deletion
|
||||||
*/
|
*/
|
||||||
fun undoDelete(note: Note) {
|
fun undoDelete(note: Note) = viewModelScope.launch{
|
||||||
// Remove from pending deletions
|
// Remove from pending deletions
|
||||||
_pendingDeletions.value = _pendingDeletions.value - note.id
|
_pendingDeletions.value = _pendingDeletions.value - note.id
|
||||||
|
|
||||||
// Restore to storage
|
// Restore to storage
|
||||||
storage.saveNote(note)
|
storage.saveNote(note)
|
||||||
|
|
||||||
// Reload notes
|
// Reload notes
|
||||||
loadNotes()
|
loadNotes()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Actually delete note from server after snackbar dismissed
|
* Actually delete note from server after snackbar dismissed
|
||||||
*/
|
*/
|
||||||
@@ -468,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val success = withContext(Dispatchers.IO) {
|
val success = withContext(Dispatchers.IO) {
|
||||||
webdavService.deleteNoteFromServer(noteId)
|
webdavService.deleteNoteFromServer(noteId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
|
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
|
||||||
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
|
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
|
||||||
@@ -483,7 +488,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete multiple notes from server with aggregated toast
|
* Delete multiple notes from server with aggregated toast
|
||||||
* Shows single toast at the end instead of one per note
|
* Shows single toast at the end instead of one per note
|
||||||
@@ -493,7 +498,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val webdavService = WebDavSyncService(getApplication())
|
val webdavService = WebDavSyncService(getApplication())
|
||||||
var successCount = 0
|
var successCount = 0
|
||||||
var failCount = 0
|
var failCount = 0
|
||||||
|
|
||||||
noteIds.forEach { noteId ->
|
noteIds.forEach { noteId ->
|
||||||
try {
|
try {
|
||||||
val success = withContext(Dispatchers.IO) {
|
val success = withContext(Dispatchers.IO) {
|
||||||
@@ -507,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
|
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
|
||||||
val message = when {
|
val message = when {
|
||||||
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
|
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
|
||||||
@@ -525,22 +530,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize deletion (remove from pending set)
|
* Finalize deletion (remove from pending set)
|
||||||
*/
|
*/
|
||||||
fun finalizeDeletion(noteId: String) {
|
fun finalizeDeletion(noteId: String) {
|
||||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Sync Actions
|
// Sync Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
fun updateSyncState(status: SyncStateManager.SyncStatus) {
|
fun updateSyncState(status: SyncStateManager.SyncStatus) {
|
||||||
_syncState.value = status.state
|
_syncState.value = status.state
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
||||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||||
@@ -559,14 +564,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach)
|
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach)
|
||||||
// Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren)
|
// Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren)
|
||||||
val prefs = getApplication<android.app.Application>().getSharedPreferences(
|
val prefs = getApplication<android.app.Application>().getSharedPreferences(
|
||||||
Constants.PREFS_NAME,
|
Constants.PREFS_NAME,
|
||||||
android.content.Context.MODE_PRIVATE
|
android.content.Context.MODE_PRIVATE
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
|
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
|
||||||
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
|
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
|
||||||
if (!SyncStateManager.tryStartSync(source)) {
|
if (!SyncStateManager.tryStartSync(source)) {
|
||||||
@@ -582,10 +587,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch)
|
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch)
|
||||||
SyncStateManager.markGlobalSyncStarted(prefs)
|
SyncStateManager.markGlobalSyncStarted(prefs)
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
// Check for unsynced changes (Banner zeigt bereits PREPARING)
|
// Check for unsynced changes (Banner zeigt bereits PREPARING)
|
||||||
@@ -595,23 +600,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
loadNotes()
|
loadNotes()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check server reachability
|
// Check server reachability
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
syncService.isServerReachable()
|
syncService.isServerReachable()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isReachable) {
|
if (!isReachable) {
|
||||||
Logger.d(TAG, "⏭️ $source Sync: Server not reachable")
|
Logger.d(TAG, "⏭️ $source Sync: Server not reachable")
|
||||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform sync
|
// Perform sync
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
syncService.syncNotes()
|
syncService.syncNotes()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
// 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen
|
// 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen
|
||||||
val bannerMessage = buildString {
|
val bannerMessage = buildString {
|
||||||
@@ -636,7 +641,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger auto-sync (onResume)
|
* Trigger auto-sync (onResume)
|
||||||
* Only runs if server is configured and interval has passed
|
* Only runs if server is configured and interval has passed
|
||||||
@@ -650,17 +655,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
|
Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen)
|
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen)
|
||||||
if (!SyncStateManager.canSyncGlobally(prefs)) {
|
if (!SyncStateManager.canSyncGlobally(prefs)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throttling check (eigener 60s-Cooldown für onResume)
|
// Throttling check (eigener 60s-Cooldown für onResume)
|
||||||
if (!canTriggerAutoSync()) {
|
if (!canTriggerAutoSync()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||||
val syncService = WebDavSyncService(getApplication())
|
val syncService = WebDavSyncService(getApplication())
|
||||||
val gateResult = syncService.canSync()
|
val gateResult = syncService.canSync()
|
||||||
@@ -672,22 +677,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.5.0: silent=true → kein Banner bei Auto-Sync
|
// v1.5.0: silent=true → kein Banner bei Auto-Sync
|
||||||
// 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar
|
// 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar
|
||||||
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||||
|
|
||||||
// Update last sync timestamp
|
// Update last sync timestamp
|
||||||
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||||
|
|
||||||
// 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren
|
// 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren
|
||||||
SyncStateManager.markGlobalSyncStarted(prefs)
|
SyncStateManager.markGlobalSyncStarted(prefs)
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
// Check for unsynced changes
|
// Check for unsynced changes
|
||||||
@@ -696,23 +701,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
SyncStateManager.reset() // Silent → geht direkt auf IDLE
|
SyncStateManager.reset() // Silent → geht direkt auf IDLE
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check server reachability
|
// Check server reachability
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
syncService.isServerReachable()
|
syncService.isServerReachable()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isReachable) {
|
if (!isReachable) {
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||||
SyncStateManager.reset() // Silent → kein Error-Banner
|
SyncStateManager.reset() // Silent → kein Error-Banner
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform sync
|
// Perform sync
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
syncService.syncNotes()
|
syncService.syncNotes()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.isSuccess && result.syncedCount > 0) {
|
if (result.isSuccess && result.syncedCount > 0) {
|
||||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||||
// 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync
|
// 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync
|
||||||
@@ -734,25 +739,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canTriggerAutoSync(): Boolean {
|
private fun canTriggerAutoSync(): Boolean {
|
||||||
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val timeSinceLastSync = now - lastSyncTime
|
val timeSinceLastSync = now - lastSyncTime
|
||||||
|
|
||||||
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
||||||
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||||
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// 🔀 v1.8.0: Sortierung
|
// 🔀 v1.8.0: Sortierung
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung.
|
* 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung.
|
||||||
*/
|
*/
|
||||||
@@ -768,13 +773,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
SortOption.NOTE_TYPE -> compareBy<Note> { it.noteType.ordinal }
|
SortOption.NOTE_TYPE -> compareBy<Note> { it.noteType.ordinal }
|
||||||
.thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen
|
.thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (direction) {
|
return when (direction) {
|
||||||
SortDirection.ASCENDING -> notes.sortedWith(comparator)
|
SortDirection.ASCENDING -> notes.sortedWith(comparator)
|
||||||
SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed())
|
SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences.
|
* 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences.
|
||||||
*/
|
*/
|
||||||
@@ -783,7 +788,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply()
|
prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply()
|
||||||
Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}")
|
Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences.
|
* 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences.
|
||||||
*/
|
*/
|
||||||
@@ -792,7 +797,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply()
|
prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply()
|
||||||
Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}")
|
Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔀 v1.8.0: Toggelt die Sortierrichtung.
|
* 🔀 v1.8.0: Toggelt die Sortierrichtung.
|
||||||
*/
|
*/
|
||||||
@@ -800,16 +805,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val newDirection = _sortDirection.value.toggle()
|
val newDirection = _sortDirection.value.toggle()
|
||||||
setSortDirection(newDirection)
|
setSortDirection(newDirection)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Helpers
|
// Helpers
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
|
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
|
||||||
|
|
||||||
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
||||||
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
||||||
|
|
||||||
fun isServerConfigured(): Boolean {
|
fun isServerConfigured(): Boolean {
|
||||||
// 🌟 v1.6.0: Use reactive offline mode state
|
// 🌟 v1.6.0: Use reactive offline mode state
|
||||||
if (_isOfflineMode.value) {
|
if (_isOfflineMode.value) {
|
||||||
@@ -818,7 +823,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
|
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
|
||||||
* Used for determining if sync would be available when offline mode is disabled
|
* Used for determining if sync would be available when offline mode is disabled
|
||||||
@@ -827,4 +832,37 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
@@ -26,45 +27,47 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import kotlin.getValue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ViewModel for Settings screens
|
* ViewModel for Settings screens
|
||||||
* v1.5.0: Jetpack Compose Settings Redesign
|
* v1.5.0: Jetpack Compose Settings Redesign
|
||||||
*
|
*
|
||||||
* Manages all settings state and actions across the Settings navigation graph.
|
* Manages all settings state and actions across the Settings navigation graph.
|
||||||
*/
|
*/
|
||||||
@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*)
|
@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*)
|
||||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SettingsViewModel"
|
private const val TAG = "SettingsViewModel"
|
||||||
private const val CONNECTION_TIMEOUT_MS = 3000
|
private const val CONNECTION_TIMEOUT_MS = 3000
|
||||||
private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations
|
private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations
|
||||||
private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important)
|
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)
|
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
|
// 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection
|
||||||
// This prevents false-positive "server changed" toasts during text input
|
// This prevents false-positive "server changed" toasts during text input
|
||||||
private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Server Settings State
|
// Server Settings State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// v1.5.0 Fix: Initialize URL with protocol prefix if empty
|
// v1.5.0 Fix: Initialize URL with protocol prefix if empty
|
||||||
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||||
|
|
||||||
// 🌟 v1.6.0: Separate host from prefix for better UX
|
// 🌟 v1.6.0: Separate host from prefix for better UX
|
||||||
// isHttps determines the prefix, serverHost is the editable part
|
// isHttps determines the prefix, serverHost is the editable part
|
||||||
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
|
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
|
||||||
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
|
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
|
||||||
|
|
||||||
// Extract host part (everything after http:// or https://)
|
// Extract host part (everything after http:// or https://)
|
||||||
private fun extractHostFromUrl(url: String): String {
|
private fun extractHostFromUrl(url: String): String {
|
||||||
return when {
|
return when {
|
||||||
@@ -73,26 +76,26 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
else -> url
|
else -> url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 v1.6.0: Only the host part is editable (without protocol prefix)
|
// 🌟 v1.6.0: Only the host part is editable (without protocol prefix)
|
||||||
private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl))
|
private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl))
|
||||||
val serverHost: StateFlow<String> = _serverHost.asStateFlow()
|
val serverHost: StateFlow<String> = _serverHost.asStateFlow()
|
||||||
|
|
||||||
// 🌟 v1.6.0: Full URL for display purposes (computed from prefix + host)
|
// 🌟 v1.6.0: Full URL for display purposes (computed from prefix + host)
|
||||||
val serverUrl: StateFlow<String> = combine(_isHttps, _serverHost) { https, host ->
|
val serverUrl: StateFlow<String> = combine(_isHttps, _serverHost) { https, host ->
|
||||||
val prefix = if (https) "https://" else "http://"
|
val prefix = if (https) "https://" else "http://"
|
||||||
if (host.isEmpty()) "" else prefix + host
|
if (host.isEmpty()) "" else prefix + host
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl)
|
||||||
|
|
||||||
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
|
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
|
||||||
val username: StateFlow<String> = _username.asStateFlow()
|
val username: StateFlow<String> = _username.asStateFlow()
|
||||||
|
|
||||||
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
|
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
|
||||||
val password: StateFlow<String> = _password.asStateFlow()
|
val password: StateFlow<String> = _password.asStateFlow()
|
||||||
|
|
||||||
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
|
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
|
||||||
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
|
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
|
||||||
|
|
||||||
// 🌟 v1.6.0: Offline Mode Toggle
|
// 🌟 v1.6.0: Offline Mode Toggle
|
||||||
// Default: true for new users (no server), false for existing users (has server config)
|
// Default: true for new users (no server), false for existing users (has server config)
|
||||||
private val _offlineMode = MutableStateFlow(
|
private val _offlineMode = MutableStateFlow(
|
||||||
@@ -104,35 +107,35 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
val offlineMode: StateFlow<Boolean> = _offlineMode.asStateFlow()
|
val offlineMode: StateFlow<Boolean> = _offlineMode.asStateFlow()
|
||||||
|
|
||||||
private fun hasExistingServerConfig(): Boolean {
|
private fun hasExistingServerConfig(): Boolean {
|
||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
return !serverUrl.isNullOrEmpty() &&
|
return !serverUrl.isNullOrEmpty() &&
|
||||||
serverUrl != "http://" &&
|
serverUrl != "http://" &&
|
||||||
serverUrl != "https://"
|
serverUrl != "https://"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Events (for Activity-level actions like dialogs, intents)
|
// Events (for Activity-level actions like dialogs, intents)
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _events = MutableSharedFlow<SettingsEvent>()
|
private val _events = MutableSharedFlow<SettingsEvent>()
|
||||||
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
|
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Markdown Export Progress State
|
// Markdown Export Progress State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _markdownExportProgress = MutableStateFlow<MarkdownExportProgress?>(null)
|
private val _markdownExportProgress = MutableStateFlow<MarkdownExportProgress?>(null)
|
||||||
val markdownExportProgress: StateFlow<MarkdownExportProgress?> = _markdownExportProgress.asStateFlow()
|
val markdownExportProgress: StateFlow<MarkdownExportProgress?> = _markdownExportProgress.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Sync Settings State
|
// Sync Settings State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _autoSyncEnabled = MutableStateFlow(prefs.getBoolean(Constants.KEY_AUTO_SYNC, false))
|
private val _autoSyncEnabled = MutableStateFlow(prefs.getBoolean(Constants.KEY_AUTO_SYNC, false))
|
||||||
val autoSyncEnabled: StateFlow<Boolean> = _autoSyncEnabled.asStateFlow()
|
val autoSyncEnabled: StateFlow<Boolean> = _autoSyncEnabled.asStateFlow()
|
||||||
|
|
||||||
private val _syncInterval = MutableStateFlow(
|
private val _syncInterval = MutableStateFlow(
|
||||||
prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
|
prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||||
)
|
)
|
||||||
@@ -149,82 +152,82 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
|
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
|
||||||
)
|
)
|
||||||
val triggerOnSave: StateFlow<Boolean> = _triggerOnSave.asStateFlow()
|
val triggerOnSave: StateFlow<Boolean> = _triggerOnSave.asStateFlow()
|
||||||
|
|
||||||
private val _triggerOnResume = MutableStateFlow(
|
private val _triggerOnResume = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
|
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
|
||||||
)
|
)
|
||||||
val triggerOnResume: StateFlow<Boolean> = _triggerOnResume.asStateFlow()
|
val triggerOnResume: StateFlow<Boolean> = _triggerOnResume.asStateFlow()
|
||||||
|
|
||||||
private val _triggerWifiConnect = MutableStateFlow(
|
private val _triggerWifiConnect = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
|
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
|
||||||
)
|
)
|
||||||
val triggerWifiConnect: StateFlow<Boolean> = _triggerWifiConnect.asStateFlow()
|
val triggerWifiConnect: StateFlow<Boolean> = _triggerWifiConnect.asStateFlow()
|
||||||
|
|
||||||
private val _triggerPeriodic = MutableStateFlow(
|
private val _triggerPeriodic = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
|
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
|
||||||
)
|
)
|
||||||
val triggerPeriodic: StateFlow<Boolean> = _triggerPeriodic.asStateFlow()
|
val triggerPeriodic: StateFlow<Boolean> = _triggerPeriodic.asStateFlow()
|
||||||
|
|
||||||
private val _triggerBoot = MutableStateFlow(
|
private val _triggerBoot = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
|
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
|
||||||
)
|
)
|
||||||
val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow()
|
val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow()
|
||||||
|
|
||||||
// 🎉 v1.7.0: WiFi-Only Sync Toggle
|
// 🎉 v1.7.0: WiFi-Only Sync Toggle
|
||||||
private val _wifiOnlySync = MutableStateFlow(
|
private val _wifiOnlySync = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
|
prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
|
||||||
)
|
)
|
||||||
val wifiOnlySync: StateFlow<Boolean> = _wifiOnlySync.asStateFlow()
|
val wifiOnlySync: StateFlow<Boolean> = _wifiOnlySync.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Markdown Settings State
|
// Markdown Settings State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _markdownAutoSync = MutableStateFlow(
|
private val _markdownAutoSync = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) &&
|
prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) &&
|
||||||
prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
|
prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
|
||||||
)
|
)
|
||||||
val markdownAutoSync: StateFlow<Boolean> = _markdownAutoSync.asStateFlow()
|
val markdownAutoSync: StateFlow<Boolean> = _markdownAutoSync.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Debug Settings State
|
// Debug Settings State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _fileLoggingEnabled = MutableStateFlow(
|
private val _fileLoggingEnabled = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
|
prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
|
||||||
)
|
)
|
||||||
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
|
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// 🎨 v1.7.0: Display Settings State
|
// 🎨 v1.7.0: Display Settings State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _displayMode = MutableStateFlow(
|
private val _displayMode = MutableStateFlow(
|
||||||
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||||
)
|
)
|
||||||
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// UI State
|
// UI State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private val _isSyncing = MutableStateFlow(false)
|
private val _isSyncing = MutableStateFlow(false)
|
||||||
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
|
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
|
||||||
|
|
||||||
private val _isBackupInProgress = MutableStateFlow(false)
|
private val _isBackupInProgress = MutableStateFlow(false)
|
||||||
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow()
|
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow()
|
||||||
|
|
||||||
// v1.8.0: Descriptive backup status text
|
// v1.8.0: Descriptive backup status text
|
||||||
private val _backupStatusText = MutableStateFlow("")
|
private val _backupStatusText = MutableStateFlow("")
|
||||||
val backupStatusText: StateFlow<String> = _backupStatusText.asStateFlow()
|
val backupStatusText: StateFlow<String> = _backupStatusText.asStateFlow()
|
||||||
|
|
||||||
private val _showToast = MutableSharedFlow<String>()
|
private val _showToast = MutableSharedFlow<String>()
|
||||||
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Server Settings Actions
|
// Server Settings Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v1.6.0: Set offline mode on/off
|
* v1.6.0: Set offline mode on/off
|
||||||
* When enabled, all network features are disabled
|
* When enabled, all network features are disabled
|
||||||
@@ -232,7 +235,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
_offlineMode.value = enabled
|
_offlineMode.value = enabled
|
||||||
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply()
|
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply()
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
_serverStatus.value = ServerStatus.OfflineMode
|
_serverStatus.value = ServerStatus.OfflineMode
|
||||||
} else {
|
} else {
|
||||||
@@ -240,14 +243,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
checkServerStatus()
|
checkServerStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateServerUrl(url: String) {
|
fun updateServerUrl(url: String) {
|
||||||
// 🌟 v1.6.0: Deprecated - use updateServerHost instead
|
// 🌟 v1.6.0: Deprecated - use updateServerHost instead
|
||||||
// This function is kept for compatibility but now delegates to updateServerHost
|
// This function is kept for compatibility but now delegates to updateServerHost
|
||||||
val host = extractHostFromUrl(url)
|
val host = extractHostFromUrl(url)
|
||||||
updateServerHost(host)
|
updateServerHost(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🌟 v1.6.0: Update only the host part of the server URL
|
* 🌟 v1.6.0: Update only the host part of the server URL
|
||||||
* The protocol prefix is handled separately by updateProtocol()
|
* The protocol prefix is handled separately by updateProtocol()
|
||||||
@@ -257,37 +260,37 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
*/
|
*/
|
||||||
fun updateServerHost(host: String) {
|
fun updateServerHost(host: String) {
|
||||||
_serverHost.value = host
|
_serverHost.value = host
|
||||||
|
|
||||||
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
||||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||||
val fullUrl = if (host.isEmpty()) "" else prefix + host
|
val fullUrl = if (host.isEmpty()) "" else prefix + host
|
||||||
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProtocol(useHttps: Boolean) {
|
fun updateProtocol(useHttps: Boolean) {
|
||||||
_isHttps.value = useHttps
|
_isHttps.value = useHttps
|
||||||
// 🌟 v1.6.0: Host stays the same, only prefix changes
|
// 🌟 v1.6.0: Host stays the same, only prefix changes
|
||||||
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
|
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
|
||||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||||
|
|
||||||
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
||||||
val prefix = if (useHttps) "https://" else "http://"
|
val prefix = if (useHttps) "https://" else "http://"
|
||||||
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
||||||
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateUsername(value: String) {
|
fun updateUsername(value: String) {
|
||||||
_username.value = value
|
_username.value = value
|
||||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||||
prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
|
prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updatePassword(value: String) {
|
fun updatePassword(value: String) {
|
||||||
_password.value = value
|
_password.value = value
|
||||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||||
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
|
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
|
* 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
|
||||||
* This prevents false "server changed" detection during text input
|
* This prevents false "server changed" detection during text input
|
||||||
@@ -298,17 +301,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
// 🌟 v1.6.0: Construct full URL from prefix + host
|
// 🌟 v1.6.0: Construct full URL from prefix + host
|
||||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||||
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
||||||
|
|
||||||
// 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
|
// 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
|
||||||
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
|
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
|
||||||
|
|
||||||
// ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
|
// ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
|
||||||
// This function now ONLY handles server-change detection
|
// This function now ONLY handles server-change detection
|
||||||
|
|
||||||
// Reset sync status if server actually changed
|
// Reset sync status if server actually changed
|
||||||
if (serverChanged) {
|
if (serverChanged) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val count = notesStorage.resetAllSyncStatusToPending()
|
val count = storage.resetAllSyncStatusToPending()
|
||||||
Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
|
Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
|
||||||
emitToast(getString(R.string.toast_server_changed_sync_reset, count))
|
emitToast(getString(R.string.toast_server_changed_sync_reset, count))
|
||||||
}
|
}
|
||||||
@@ -318,10 +321,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
Logger.d(TAG, "💾 Server settings check complete (no server change detected)")
|
Logger.d(TAG, "💾 Server settings check complete (no server change detected)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <20> v1.7.0 Hotfix: Improved server change detection
|
* <20> v1.7.0 Hotfix: Improved server change detection
|
||||||
*
|
*
|
||||||
* Only returns true if the server URL actually changed in a meaningful way.
|
* Only returns true if the server URL actually changed in a meaningful way.
|
||||||
* Handles edge cases:
|
* Handles edge cases:
|
||||||
* - First setup (empty → filled) = NOT a change
|
* - First setup (empty → filled) = NOT a change
|
||||||
@@ -336,23 +339,23 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
Logger.d(TAG, "First server setup detected (no reset needed)")
|
Logger.d(TAG, "First server setup detected (no reset needed)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both empty = No change
|
// Both empty = No change
|
||||||
if (confirmedUrl.isEmpty() && newUrl.isEmpty()) {
|
if (confirmedUrl.isEmpty() && newUrl.isEmpty()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-empty → Empty = Server removed (keep notes local, no reset)
|
// Non-empty → Empty = Server removed (keep notes local, no reset)
|
||||||
if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) {
|
if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) {
|
||||||
Logger.d(TAG, "Server removed (notes stay local, no reset needed)")
|
Logger.d(TAG, "Server removed (notes stay local, no reset needed)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same URL = No change
|
// Same URL = No change
|
||||||
if (confirmedUrl == newUrl) {
|
if (confirmedUrl == newUrl) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize URLs for comparison (ignore protocol, trailing slash, case)
|
// Normalize URLs for comparison (ignore protocol, trailing slash, case)
|
||||||
val normalize = { url: String ->
|
val normalize = { url: String ->
|
||||||
url.trim()
|
url.trim()
|
||||||
@@ -361,20 +364,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
.removeSuffix("/")
|
.removeSuffix("/")
|
||||||
.lowercase()
|
.lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
val confirmedNormalized = normalize(confirmedUrl)
|
val confirmedNormalized = normalize(confirmedUrl)
|
||||||
val newNormalized = normalize(newUrl)
|
val newNormalized = normalize(newUrl)
|
||||||
|
|
||||||
// Check if normalized URLs differ
|
// Check if normalized URLs differ
|
||||||
val changed = confirmedNormalized != newNormalized
|
val changed = confirmedNormalized != newNormalized
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'")
|
Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'")
|
||||||
}
|
}
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
fun testConnection() {
|
fun testConnection() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_serverStatus.value = ServerStatus.Checking
|
_serverStatus.value = ServerStatus.Checking
|
||||||
@@ -398,25 +401,25 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkServerStatus() {
|
fun checkServerStatus() {
|
||||||
// 🌟 v1.6.0: Respect offline mode first
|
// 🌟 v1.6.0: Respect offline mode first
|
||||||
if (_offlineMode.value) {
|
if (_offlineMode.value) {
|
||||||
_serverStatus.value = ServerStatus.OfflineMode
|
_serverStatus.value = ServerStatus.OfflineMode
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 v1.6.0: Check if host is configured
|
// 🌟 v1.6.0: Check if host is configured
|
||||||
val serverHost = _serverHost.value
|
val serverHost = _serverHost.value
|
||||||
if (serverHost.isEmpty()) {
|
if (serverHost.isEmpty()) {
|
||||||
_serverStatus.value = ServerStatus.NotConfigured
|
_serverStatus.value = ServerStatus.NotConfigured
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct full URL
|
// Construct full URL
|
||||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||||
val serverUrl = prefix + serverHost
|
val serverUrl = prefix + serverHost
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_serverStatus.value = ServerStatus.Checking
|
_serverStatus.value = ServerStatus.Checking
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
@@ -436,14 +439,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
_serverStatus.value = if (isReachable) ServerStatus.Reachable else ServerStatus.Unreachable(null)
|
_serverStatus.value = if (isReachable) ServerStatus.Reachable else ServerStatus.Unreachable(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun syncNow() {
|
fun syncNow() {
|
||||||
if (_isSyncing.value) return
|
if (_isSyncing.value) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isSyncing.value = true
|
_isSyncing.value = true
|
||||||
try {
|
try {
|
||||||
val syncService = WebDavSyncService(getApplication())
|
val syncService = WebDavSyncService(getApplication())
|
||||||
|
|
||||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
|
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
|
||||||
val gateResult = syncService.canSync()
|
val gateResult = syncService.canSync()
|
||||||
if (!gateResult.canSync) {
|
if (!gateResult.canSync) {
|
||||||
@@ -454,14 +457,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
emitToast(getString(R.string.toast_syncing))
|
emitToast(getString(R.string.toast_syncing))
|
||||||
|
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
emitToast(getString(R.string.toast_already_synced))
|
emitToast(getString(R.string.toast_already_synced))
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = syncService.syncNotes()
|
val result = syncService.syncNotes()
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
emitToast(getString(R.string.toast_sync_success, result.syncedCount))
|
emitToast(getString(R.string.toast_sync_success, result.syncedCount))
|
||||||
@@ -475,15 +478,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Sync Settings Actions
|
// Sync Settings Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
fun setAutoSync(enabled: Boolean) {
|
fun setAutoSync(enabled: Boolean) {
|
||||||
_autoSyncEnabled.value = enabled
|
_autoSyncEnabled.value = enabled
|
||||||
prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply()
|
prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply()
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// v1.5.0 Fix: Trigger battery optimization check and network monitor restart
|
// v1.5.0 Fix: Trigger battery optimization check and network monitor restart
|
||||||
@@ -496,7 +499,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSyncInterval(minutes: Long) {
|
fun setSyncInterval(minutes: Long) {
|
||||||
_syncInterval.value = minutes
|
_syncInterval.value = minutes
|
||||||
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, minutes).apply()
|
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, minutes).apply()
|
||||||
@@ -521,19 +524,19 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 v1.6.0: Configurable Sync Triggers Setters
|
// 🌟 v1.6.0: Configurable Sync Triggers Setters
|
||||||
|
|
||||||
fun setTriggerOnSave(enabled: Boolean) {
|
fun setTriggerOnSave(enabled: Boolean) {
|
||||||
_triggerOnSave.value = enabled
|
_triggerOnSave.value = enabled
|
||||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply()
|
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply()
|
||||||
Logger.d(TAG, "Trigger onSave: $enabled")
|
Logger.d(TAG, "Trigger onSave: $enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTriggerOnResume(enabled: Boolean) {
|
fun setTriggerOnResume(enabled: Boolean) {
|
||||||
_triggerOnResume.value = enabled
|
_triggerOnResume.value = enabled
|
||||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply()
|
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply()
|
||||||
Logger.d(TAG, "Trigger onResume: $enabled")
|
Logger.d(TAG, "Trigger onResume: $enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTriggerWifiConnect(enabled: Boolean) {
|
fun setTriggerWifiConnect(enabled: Boolean) {
|
||||||
_triggerWifiConnect.value = enabled
|
_triggerWifiConnect.value = enabled
|
||||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply()
|
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply()
|
||||||
@@ -542,7 +545,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
Logger.d(TAG, "Trigger WiFi-Connect: $enabled")
|
Logger.d(TAG, "Trigger WiFi-Connect: $enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTriggerPeriodic(enabled: Boolean) {
|
fun setTriggerPeriodic(enabled: Boolean) {
|
||||||
_triggerPeriodic.value = enabled
|
_triggerPeriodic.value = enabled
|
||||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply()
|
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply()
|
||||||
@@ -551,13 +554,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
Logger.d(TAG, "Trigger Periodic: $enabled")
|
Logger.d(TAG, "Trigger Periodic: $enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTriggerBoot(enabled: Boolean) {
|
fun setTriggerBoot(enabled: Boolean) {
|
||||||
_triggerBoot.value = enabled
|
_triggerBoot.value = enabled
|
||||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply()
|
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply()
|
||||||
Logger.d(TAG, "Trigger Boot: $enabled")
|
Logger.d(TAG, "Trigger Boot: $enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🎉 v1.7.0: Set WiFi-only sync mode
|
* 🎉 v1.7.0: Set WiFi-only sync mode
|
||||||
* When enabled, sync only happens when connected to WiFi
|
* When enabled, sync only happens when connected to WiFi
|
||||||
@@ -567,11 +570,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply()
|
prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply()
|
||||||
Logger.d(TAG, "📡 WiFi-only sync: $enabled")
|
Logger.d(TAG, "📡 WiFi-only sync: $enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Markdown Settings Actions
|
// Markdown Settings Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
fun setMarkdownAutoSync(enabled: Boolean) {
|
fun setMarkdownAutoSync(enabled: Boolean) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// v1.5.0 Fix: Perform initial export when enabling (like old SettingsActivity)
|
// v1.5.0 Fix: Perform initial export when enabling (like old SettingsActivity)
|
||||||
@@ -581,21 +584,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||||
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
|
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
|
||||||
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
|
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
|
||||||
|
|
||||||
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||||
emitToast(getString(R.string.toast_configure_server_first))
|
emitToast(getString(R.string.toast_configure_server_first))
|
||||||
// Don't enable - revert state
|
// Don't enable - revert state
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are notes to export
|
// Check if there are notes to export
|
||||||
val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(getApplication())
|
val noteCount = storage.loadAllNotes().size
|
||||||
val noteCount = noteStorage.loadAllNotes().size
|
|
||||||
|
|
||||||
if (noteCount > 0) {
|
if (noteCount > 0) {
|
||||||
// Show progress and perform initial export
|
// Show progress and perform initial export
|
||||||
_markdownExportProgress.value = MarkdownExportProgress(0, noteCount)
|
_markdownExportProgress.value = MarkdownExportProgress(0, noteCount)
|
||||||
|
|
||||||
val syncService = WebDavSyncService(getApplication())
|
val syncService = WebDavSyncService(getApplication())
|
||||||
val exportedCount = withContext(Dispatchers.IO) {
|
val exportedCount = withContext(Dispatchers.IO) {
|
||||||
syncService.exportAllNotesToMarkdown(
|
syncService.exportAllNotesToMarkdown(
|
||||||
@@ -607,22 +609,22 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export successful - save settings
|
// Export successful - save settings
|
||||||
_markdownAutoSync.value = true
|
_markdownAutoSync.value = true
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
|
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
|
||||||
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
|
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
|
||||||
.apply()
|
.apply()
|
||||||
|
|
||||||
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
|
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
|
||||||
emitToast(getString(R.string.toast_markdown_exported, exportedCount))
|
emitToast(getString(R.string.toast_markdown_exported, exportedCount))
|
||||||
|
|
||||||
@Suppress("MagicNumber") // UI progress delay
|
@Suppress("MagicNumber") // UI progress delay
|
||||||
// Clear progress after short delay
|
// Clear progress after short delay
|
||||||
kotlinx.coroutines.delay(500)
|
kotlinx.coroutines.delay(500)
|
||||||
_markdownExportProgress.value = null
|
_markdownExportProgress.value = null
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// No notes - just enable the feature
|
// No notes - just enable the feature
|
||||||
_markdownAutoSync.value = true
|
_markdownAutoSync.value = true
|
||||||
@@ -632,7 +634,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
.apply()
|
.apply()
|
||||||
emitToast(getString(R.string.toast_markdown_enabled))
|
emitToast(getString(R.string.toast_markdown_enabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_markdownExportProgress.value = null
|
_markdownExportProgress.value = null
|
||||||
emitToast(getString(R.string.toast_export_failed, e.message ?: ""))
|
emitToast(getString(R.string.toast_export_failed, e.message ?: ""))
|
||||||
@@ -651,14 +653,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun performManualMarkdownSync() {
|
fun performManualMarkdownSync() {
|
||||||
// 🌟 v1.6.0: Block in offline mode
|
// 🌟 v1.6.0: Block in offline mode
|
||||||
if (_offlineMode.value) {
|
if (_offlineMode.value) {
|
||||||
Logger.d(TAG, "⏭️ Manual Markdown sync blocked: Offline mode enabled")
|
Logger.d(TAG, "⏭️ Manual Markdown sync blocked: Offline mode enabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
emitToast(getString(R.string.toast_markdown_syncing))
|
emitToast(getString(R.string.toast_markdown_syncing))
|
||||||
@@ -670,28 +672,28 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Backup Actions
|
// Backup Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
fun createBackup(uri: Uri, password: String? = null) {
|
fun createBackup(uri: Uri, password: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
_backupStatusText.value = getString(R.string.backup_progress_creating)
|
_backupStatusText.value = getString(R.string.backup_progress_creating)
|
||||||
try {
|
try {
|
||||||
val result = backupManager.createBackup(uri, password)
|
val result = backupManager.createBackup(uri, password)
|
||||||
|
|
||||||
// Phase 2: Show completion status
|
// Phase 2: Show completion status
|
||||||
_backupStatusText.value = if (result.success) {
|
_backupStatusText.value = if (result.success) {
|
||||||
getString(R.string.backup_progress_complete)
|
getString(R.string.backup_progress_complete)
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.backup_progress_failed)
|
getString(R.string.backup_progress_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Clear after delay
|
// Phase 3: Clear after delay
|
||||||
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to create backup", e)
|
Logger.e(TAG, "Failed to create backup", e)
|
||||||
_backupStatusText.value = getString(R.string.backup_progress_failed)
|
_backupStatusText.value = getString(R.string.backup_progress_failed)
|
||||||
@@ -702,24 +704,24 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
|
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
_backupStatusText.value = getString(R.string.backup_progress_restoring)
|
_backupStatusText.value = getString(R.string.backup_progress_restoring)
|
||||||
try {
|
try {
|
||||||
val result = backupManager.restoreBackup(uri, mode, password)
|
val result = backupManager.restoreBackup(uri, mode, password)
|
||||||
|
|
||||||
// Phase 2: Show completion status
|
// Phase 2: Show completion status
|
||||||
_backupStatusText.value = if (result.success) {
|
_backupStatusText.value = if (result.success) {
|
||||||
getString(R.string.restore_progress_complete)
|
getString(R.string.restore_progress_complete)
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.restore_progress_failed)
|
getString(R.string.restore_progress_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Clear after delay
|
// Phase 3: Clear after delay
|
||||||
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to restore backup from file", e)
|
Logger.e(TAG, "Failed to restore backup from file", e)
|
||||||
_backupStatusText.value = getString(R.string.restore_progress_failed)
|
_backupStatusText.value = getString(R.string.restore_progress_failed)
|
||||||
@@ -730,7 +732,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback
|
* 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback
|
||||||
*/
|
*/
|
||||||
@@ -753,7 +755,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restoreFromServer(mode: RestoreMode) {
|
fun restoreFromServer(mode: RestoreMode) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
@@ -763,17 +765,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
syncService.restoreFromServer(mode)
|
syncService.restoreFromServer(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Show completion status
|
// Phase 2: Show completion status
|
||||||
_backupStatusText.value = if (result.isSuccess) {
|
_backupStatusText.value = if (result.isSuccess) {
|
||||||
getString(R.string.restore_server_progress_complete)
|
getString(R.string.restore_server_progress_complete)
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.restore_server_progress_failed)
|
getString(R.string.restore_server_progress_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Clear after delay
|
// Phase 3: Clear after delay
|
||||||
delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to restore from server", e)
|
Logger.e(TAG, "Failed to restore from server", e)
|
||||||
_backupStatusText.value = getString(R.string.restore_server_progress_failed)
|
_backupStatusText.value = getString(R.string.restore_server_progress_failed)
|
||||||
@@ -784,11 +786,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Debug Settings Actions
|
// Debug Settings Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
fun setFileLogging(enabled: Boolean) {
|
fun setFileLogging(enabled: Boolean) {
|
||||||
_fileLoggingEnabled.value = enabled
|
_fileLoggingEnabled.value = enabled
|
||||||
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, enabled).apply()
|
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, enabled).apply()
|
||||||
@@ -797,7 +799,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
emitToast(if (enabled) getString(R.string.toast_file_logging_enabled) else getString(R.string.toast_file_logging_disabled))
|
emitToast(if (enabled) getString(R.string.toast_file_logging_enabled) else getString(R.string.toast_file_logging_disabled))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearLogs() {
|
fun clearLogs() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -808,9 +810,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLogFile() = Logger.getLogFile(getApplication())
|
fun getLogFile() = Logger.getLogFile(getApplication())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v1.8.0: Reset changelog version to force showing the changelog dialog on next start
|
* v1.8.0: Reset changelog version to force showing the changelog dialog on next start
|
||||||
* Used for testing the post-update changelog feature
|
* Used for testing the post-update changelog feature
|
||||||
@@ -820,11 +822,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
|
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Helper
|
// Helper
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if server is configured AND not in offline mode
|
* Check if server is configured AND not in offline mode
|
||||||
* v1.6.0: Returns false if offline mode is enabled
|
* v1.6.0: Returns false if offline mode is enabled
|
||||||
@@ -832,16 +834,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun isServerConfigured(): Boolean {
|
fun isServerConfigured(): Boolean {
|
||||||
// Offline mode takes priority
|
// Offline mode takes priority
|
||||||
if (_offlineMode.value) return false
|
if (_offlineMode.value) return false
|
||||||
|
|
||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
return !serverUrl.isNullOrEmpty() &&
|
return !serverUrl.isNullOrEmpty() &&
|
||||||
serverUrl != "http://" &&
|
serverUrl != "http://" &&
|
||||||
serverUrl != "https://"
|
serverUrl != "https://"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🌍 v1.7.1: Get string resources with correct app locale
|
* 🌍 v1.7.1: Get string resources with correct app locale
|
||||||
*
|
*
|
||||||
* AndroidViewModel uses Application context which may not have the correct locale
|
* AndroidViewModel uses Application context which may not have the correct locale
|
||||||
* applied when using per-app language settings. We need to get a Context that
|
* applied when using per-app language settings. We need to get a Context that
|
||||||
* respects AppCompatDelegate.getApplicationLocales().
|
* respects AppCompatDelegate.getApplicationLocales().
|
||||||
@@ -860,7 +862,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
return context.getString(resId)
|
return context.getString(resId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getString(resId: Int, vararg formatArgs: Any): String {
|
private fun getString(resId: Int, vararg formatArgs: Any): String {
|
||||||
// Get context with correct locale configuration from AppCompatDelegate
|
// Get context with correct locale configuration from AppCompatDelegate
|
||||||
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
|
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
|
||||||
@@ -875,11 +877,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
return context.getString(resId, *formatArgs)
|
return context.getString(resId, *formatArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun emitToast(message: String) {
|
private suspend fun emitToast(message: String) {
|
||||||
_showToast.emit(message)
|
_showToast.emit(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server status states
|
* Server status states
|
||||||
* v1.6.0: Added OfflineMode state
|
* v1.6.0: Added OfflineMode state
|
||||||
@@ -892,7 +894,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
data object Reachable : ServerStatus()
|
data object Reachable : ServerStatus()
|
||||||
data class Unreachable(val error: String?) : ServerStatus()
|
data class Unreachable(val error: String?) : ServerStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events for Activity-level actions (dialogs, intents, etc.)
|
* Events for Activity-level actions (dialogs, intents, etc.)
|
||||||
* v1.5.0: Ported from old SettingsActivity
|
* v1.5.0: Ported from old SettingsActivity
|
||||||
@@ -901,7 +903,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
data object RequestBatteryOptimization : SettingsEvent()
|
data object RequestBatteryOptimization : SettingsEvent()
|
||||||
data object RestartNetworkMonitor : SettingsEvent()
|
data object RestartNetworkMonitor : SettingsEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress state for Markdown export
|
* Progress state for Markdown export
|
||||||
* v1.5.0: For initial export progress dialog
|
* v1.5.0: For initial export progress dialog
|
||||||
@@ -911,11 +913,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
val total: Int,
|
val total: Int,
|
||||||
val isComplete: Boolean = false
|
val isComplete: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// 🎨 v1.7.0: Display Mode Functions
|
// 🎨 v1.7.0: Display Mode Functions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set display mode (list or grid)
|
* Set display mode (list or grid)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.dettmer.simplenotes.widget
|
package dev.dettmer.simplenotes.widget
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
@@ -12,6 +13,9 @@ import androidx.glance.appwidget.provideContent
|
|||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
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
|
* 🆕 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 val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val storage = NotesStorage(context)
|
|
||||||
|
|
||||||
provideContent {
|
provideContent {
|
||||||
val prefs = currentState<Preferences>()
|
val prefs = currentState<Preferences>()
|
||||||
@@ -65,7 +70,7 @@ class NoteWidget : GlanceAppWidget() {
|
|||||||
val bgOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f
|
val bgOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f
|
||||||
|
|
||||||
val note = noteId?.let { nId ->
|
val note = noteId?.let { nId ->
|
||||||
storage.loadNote(nId)
|
runBlocking { storage.loadNote(nId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
GlanceTheme {
|
GlanceTheme {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.models.ChecklistSortOption
|
|||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🆕 v1.8.0: ActionParameter Keys für Widget-Interaktionen
|
* 🆕 v1.8.0: ActionParameter Keys für Widget-Interaktionen
|
||||||
@@ -35,6 +36,9 @@ class ToggleChecklistItemAction : ActionCallback {
|
|||||||
private const val TAG = "ToggleChecklistItem"
|
private const val TAG = "ToggleChecklistItem"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||||
|
|
||||||
|
|
||||||
override suspend fun onAction(
|
override suspend fun onAction(
|
||||||
context: Context,
|
context: Context,
|
||||||
glanceId: GlanceId,
|
glanceId: GlanceId,
|
||||||
@@ -43,7 +47,6 @@ class ToggleChecklistItemAction : ActionCallback {
|
|||||||
val noteId = parameters[NoteWidgetActionKeys.KEY_NOTE_ID] ?: return
|
val noteId = parameters[NoteWidgetActionKeys.KEY_NOTE_ID] ?: return
|
||||||
val itemId = parameters[NoteWidgetActionKeys.KEY_ITEM_ID] ?: return
|
val itemId = parameters[NoteWidgetActionKeys.KEY_ITEM_ID] ?: return
|
||||||
|
|
||||||
val storage = NotesStorage(context)
|
|
||||||
val note = storage.loadNote(noteId) ?: return
|
val note = storage.loadNote(noteId) ?: return
|
||||||
|
|
||||||
val updatedItems = note.checklistItems?.map { item ->
|
val updatedItems = note.checklistItems?.map { item ->
|
||||||
@@ -167,11 +170,11 @@ class OpenConfigAction : ActionCallback {
|
|||||||
updateAppWidgetState(context, glanceId) { prefs ->
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
|
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config-Activity als Reconfigure öffnen
|
// Config-Activity als Reconfigure öffnen
|
||||||
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(context)
|
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(context)
|
||||||
val appWidgetId = glanceManager.getAppWidgetId(glanceId)
|
val appWidgetId = glanceManager.getAppWidgetId(glanceId)
|
||||||
|
|
||||||
val intent = android.content.Intent(context, NoteWidgetConfigActivity::class.java).apply {
|
val intent = android.content.Intent(context, NoteWidgetConfigActivity::class.java).apply {
|
||||||
putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
// 🐛 FIX: Eigener Task, damit finish() nicht die MainActivity zeigt
|
// 🐛 FIX: Eigener Task, damit finish() nicht die MainActivity zeigt
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
|
import kotlin.getValue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🆕 v1.8.0: Konfigurations-Activity beim Platzieren eines Widgets
|
* 🆕 v1.8.0: Konfigurations-Activity beim Platzieren eines Widgets
|
||||||
@@ -40,6 +42,8 @@ class NoteWidgetConfigActivity : ComponentActivity() {
|
|||||||
private var currentLockState: Boolean = false
|
private var currentLockState: Boolean = false
|
||||||
private var currentOpacity: Float = 1.0f
|
private var currentOpacity: Float = 1.0f
|
||||||
|
|
||||||
|
private val storage: NotesStorage by inject(NotesStorage::class.java)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -69,13 +73,12 @@ class NoteWidgetConfigActivity : ComponentActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val storage = NotesStorage(this)
|
|
||||||
|
|
||||||
// Bestehende Konfiguration laden (für Reconfigure)
|
// Bestehende Konfiguration laden (für Reconfigure)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
var existingNoteId: String? = null
|
var existingNoteId: String? = null
|
||||||
var existingLock = false
|
var existingLock = false
|
||||||
var existingOpacity = 1.0f
|
var existingOpacity = 1.0f
|
||||||
|
val notes = storage.loadAllNotes()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity)
|
val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity)
|
||||||
@@ -100,7 +103,7 @@ class NoteWidgetConfigActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
SimpleNotesTheme {
|
SimpleNotesTheme {
|
||||||
NoteWidgetConfigScreen(
|
NoteWidgetConfigScreen(
|
||||||
storage = storage,
|
notes = notes,
|
||||||
initialLock = existingLock,
|
initialLock = existingLock,
|
||||||
initialOpacity = existingOpacity,
|
initialOpacity = existingOpacity,
|
||||||
selectedNoteId = existingNoteId,
|
selectedNoteId = existingNoteId,
|
||||||
@@ -145,7 +148,7 @@ class NoteWidgetConfigActivity : ComponentActivity() {
|
|||||||
AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId
|
AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId
|
||||||
)
|
)
|
||||||
setResult(RESULT_OK, resultIntent)
|
setResult(RESULT_OK, resultIntent)
|
||||||
|
|
||||||
// 🐛 FIX: Zurück zum Homescreen statt zur MainActivity
|
// 🐛 FIX: Zurück zum Homescreen statt zur MainActivity
|
||||||
// moveTaskToBack() bringt den Task in den Hintergrund → Homescreen wird sichtbar
|
// moveTaskToBack() bringt den Task in den Hintergrund → Homescreen wird sichtbar
|
||||||
if (!isTaskRoot) {
|
if (!isTaskRoot) {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.models.NoteType
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,16 +58,16 @@ private const val NOTE_PREVIEW_MAX_LENGTH = 50
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NoteWidgetConfigScreen(
|
fun NoteWidgetConfigScreen(
|
||||||
storage: NotesStorage,
|
notes: List<Note>,
|
||||||
initialLock: Boolean = false,
|
initialLock: Boolean = false,
|
||||||
initialOpacity: Float = 1.0f,
|
initialOpacity: Float = 1.0f,
|
||||||
selectedNoteId: String? = null,
|
selectedNoteId: String? = null,
|
||||||
onNoteSelected: (noteId: String, isLocked: Boolean, opacity: Float) -> Unit,
|
onNoteSelected: (String, Boolean, Float) -> Unit,
|
||||||
onSave: ((noteId: String, isLocked: Boolean, opacity: Float) -> Unit)? = null,
|
onSave: ((String, Boolean, Float) -> Unit)? = null,
|
||||||
onSettingsChanged: ((noteId: String?, isLocked: Boolean, opacity: Float) -> Unit)? = null,
|
onSettingsChanged: ((String?, Boolean, Float) -> Unit)? = null,
|
||||||
@Suppress("UNUSED_PARAMETER") onCancel: () -> Unit // Reserved for future use
|
@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 lockWidget by remember { mutableStateOf(initialLock) }
|
||||||
var opacity by remember { mutableFloatStateOf(initialOpacity) }
|
var opacity by remember { mutableFloatStateOf(initialOpacity) }
|
||||||
var currentSelectedId by remember { mutableStateOf(selectedNoteId) }
|
var currentSelectedId by remember { mutableStateOf(selectedNoteId) }
|
||||||
|
|||||||
@@ -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"
|
navigationCompose = "2.7.6"
|
||||||
lifecycleRuntimeCompose = "2.7.0"
|
lifecycleRuntimeCompose = "2.7.0"
|
||||||
activityCompose = "1.8.2"
|
activityCompose = "1.8.2"
|
||||||
|
room = "2.6.1"
|
||||||
|
ksp = "2.0.21-1.0.27"
|
||||||
|
koin = "3.5.3"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -37,6 +40,14 @@ androidx-compose-material-icons = { group = "androidx.compose.material", name =
|
|||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
|
||||||
|
# Room Database
|
||||||
|
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
# Core Koin for Kotlin projects
|
||||||
|
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
||||||
|
# Koin for Jetpack Compose integration
|
||||||
|
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
@@ -44,4 +55,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
|||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
|
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
|
||||||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user