diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 9e188b2..2115304 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -99,6 +99,11 @@ android {
buildConfig = true // Enable BuildConfig generation
compose = true // v1.5.0: Jetpack Compose fΓΌr Settings Redesign
}
+
+ // v1.5.0 Hotfix: Strong Skipping Mode fΓΌr bessere 120Hz Performance
+ composeCompiler {
+ enableStrongSkippingMode = true
+ }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0231cc6..7c8e354 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -27,8 +27,9 @@
android:theme="@style/Theme.SimpleNotes"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">
+
@@ -37,21 +38,27 @@
+
+
+
+ android:parentActivityName=".ui.main.ComposeMainActivity" />
+ android:parentActivityName=".ui.main.ComposeMainActivity" />
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt
index 6dce920..5c64a3a 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt
@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.models
+import androidx.compose.runtime.Immutable
import dev.dettmer.simplenotes.utils.Logger
import java.text.SimpleDateFormat
import java.util.Date
@@ -7,6 +8,12 @@ import java.util.Locale
import java.util.TimeZone
import java.util.UUID
+/**
+ * Note data class with Compose stability annotation.
+ * @Immutable tells Compose this class is stable and won't change unexpectedly,
+ * enabling skip optimizations during recomposition.
+ */
+@Immutable
data class Note(
val id: String = UUID.randomUUID().toString(),
val title: String,
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
new file mode 100644
index 0000000..975369e
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
@@ -0,0 +1,370 @@
+package dev.dettmer.simplenotes.ui.main
+
+import android.Manifest
+import android.app.ActivityOptions
+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 android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.lifecycleScope
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import com.google.android.material.color.DynamicColors
+import dev.dettmer.simplenotes.NoteEditorActivity
+import dev.dettmer.simplenotes.models.NoteType
+import dev.dettmer.simplenotes.models.SyncStatus
+import dev.dettmer.simplenotes.storage.NotesStorage
+import dev.dettmer.simplenotes.sync.SyncStateManager
+import dev.dettmer.simplenotes.sync.SyncWorker
+import dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity
+import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
+import dev.dettmer.simplenotes.utils.Constants
+import dev.dettmer.simplenotes.utils.Logger
+import dev.dettmer.simplenotes.utils.NotificationHelper
+import kotlinx.coroutines.launch
+
+/**
+ * Main Activity with Jetpack Compose UI
+ * v1.5.0: Complete MainActivity Redesign with Compose
+ *
+ * Replaces the old 805-line MainActivity.kt with a modern
+ * Compose-based implementation featuring:
+ * - Notes list with swipe-to-delete
+ * - Pull-to-refresh for sync
+ * - FAB with note type selection
+ * - Material 3 Design with Dynamic Colors (Material You)
+ * - Design consistent with ComposeSettingsActivity
+ */
+class ComposeMainActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "ComposeMainActivity"
+ private const val REQUEST_NOTIFICATION_PERMISSION = 1001
+ private const val REQUEST_SETTINGS = 1002
+ }
+
+ private val viewModel: MainViewModel by viewModels()
+
+ private val prefs by lazy {
+ getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
+ }
+
+ // Phase 3: Track if coming from editor to scroll to top
+ private var cameFromEditor = false
+
+ /**
+ * BroadcastReceiver for 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) {
+ viewModel.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 Material You (Android 12+)
+ DynamicColors.applyToActivityIfAvailable(this)
+
+ // Enable edge-to-edge display
+ enableEdgeToEdge()
+
+ // Initialize Logger and enable file logging if configured
+ Logger.init(this)
+ if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
+ Logger.setFileLoggingEnabled(true)
+ }
+
+ // Clear old sync notifications on app start
+ NotificationHelper.clearSyncNotifications(this)
+
+ // Request notification permission (Android 13+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ requestNotificationPermission()
+ }
+
+ // v1.4.1: Migrate checklists for backwards compatibility
+ migrateChecklistsForBackwardsCompat()
+
+ // Setup Sync State Observer
+ setupSyncStateObserver()
+
+ setContent {
+ SimpleNotesTheme {
+ val context = LocalContext.current
+
+ // Dialog state for delete confirmation
+ var deleteDialogData by remember { mutableStateOf(null) }
+
+ // Handle delete dialog events
+ LaunchedEffect(Unit) {
+ viewModel.showDeleteDialog.collect { data ->
+ deleteDialogData = data
+ }
+ }
+
+ // Handle toast events
+ LaunchedEffect(Unit) {
+ viewModel.showToast.collect { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ // Delete confirmation dialog
+ deleteDialogData?.let { data ->
+ DeleteConfirmationDialog(
+ noteTitle = data.note.title,
+ onDismiss = {
+ viewModel.restoreNoteAfterSwipe(data.originalList)
+ deleteDialogData = null
+ },
+ onDeleteLocal = {
+ viewModel.deleteNoteConfirmed(data.note, deleteFromServer = false)
+ deleteDialogData = null
+ },
+ onDeleteFromServer = {
+ viewModel.deleteNoteConfirmed(data.note, deleteFromServer = true)
+ deleteDialogData = null
+ }
+ )
+ }
+
+ MainScreen(
+ viewModel = viewModel,
+ onOpenNote = { noteId -> openNoteEditor(noteId) },
+ onOpenSettings = { openSettings() },
+ onCreateNote = { noteType -> createNote(noteType) }
+ )
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ Logger.d(TAG, "π± ComposeMainActivity.onResume() - Registering receivers")
+
+ // Register BroadcastReceiver for Background-Sync
+ LocalBroadcastManager.getInstance(this).registerReceiver(
+ syncCompletedReceiver,
+ IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
+ )
+
+ Logger.d(TAG, "π‘ BroadcastReceiver registered (sync-completed)")
+
+ // Reload notes
+ viewModel.loadNotes()
+
+ // Phase 3: Scroll to top if coming from editor (new/edited note)
+ if (cameFromEditor) {
+ viewModel.scrollToTop()
+ cameFromEditor = false
+ Logger.d(TAG, "π Came from editor - scrolling to top")
+ }
+
+ // Trigger Auto-Sync on app resume
+ viewModel.triggerAutoSync("onResume")
+ }
+
+ override fun onPause() {
+ super.onPause()
+
+ // Unregister BroadcastReceiver
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
+ Logger.d(TAG, "π‘ BroadcastReceiver unregistered")
+ }
+
+ private fun setupSyncStateObserver() {
+ SyncStateManager.syncStatus.observe(this) { status ->
+ viewModel.updateSyncState(status)
+
+ // Hide banner after delay for completed/error states
+ when (status.state) {
+ SyncStateManager.SyncState.COMPLETED -> {
+ lifecycleScope.launch {
+ kotlinx.coroutines.delay(1500L)
+ SyncStateManager.reset()
+ }
+ }
+ SyncStateManager.SyncState.ERROR -> {
+ lifecycleScope.launch {
+ kotlinx.coroutines.delay(3000L)
+ SyncStateManager.reset()
+ }
+ }
+ else -> { /* No action needed */ }
+ }
+ }
+ }
+
+ private fun openNoteEditor(noteId: String?) {
+ cameFromEditor = true
+ val intent = Intent(this, NoteEditorActivity::class.java)
+ noteId?.let {
+ intent.putExtra(NoteEditorActivity.EXTRA_NOTE_ID, it)
+ }
+ startActivity(intent)
+ }
+
+ private fun createNote(noteType: NoteType) {
+ cameFromEditor = true
+ val intent = Intent(this, NoteEditorActivity::class.java)
+ intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
+ startActivity(intent)
+ }
+
+ private fun openSettings() {
+ val intent = Intent(this, ComposeSettingsActivity::class.java)
+ val options = ActivityOptions.makeCustomAnimation(
+ this,
+ dev.dettmer.simplenotes.R.anim.slide_in_right,
+ dev.dettmer.simplenotes.R.anim.slide_out_left
+ )
+ @Suppress("DEPRECATION")
+ startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
+ }
+
+ private fun requestNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
+ != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS),
+ REQUEST_NOTIFICATION_PERMISSION
+ )
+ }
+ }
+ }
+
+ /**
+ * v1.4.1: Migrates existing checklists for backwards compatibility.
+ */
+ private fun migrateChecklistsForBackwardsCompat() {
+ val migrationKey = "v1.4.1_checklist_migration_done"
+
+ // Only run once
+ if (prefs.getBoolean(migrationKey, false)) {
+ return
+ }
+
+ val storage = NotesStorage(this)
+ val allNotes = storage.loadAllNotes()
+ val checklistsToMigrate = allNotes.filter { note ->
+ note.noteType == NoteType.CHECKLIST &&
+ note.content.isBlank() &&
+ note.checklistItems?.isNotEmpty() == true
+ }
+
+ if (checklistsToMigrate.isNotEmpty()) {
+ Logger.d(TAG, "π v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
+
+ for (note in checklistsToMigrate) {
+ val updatedNote = note.copy(
+ syncStatus = SyncStatus.PENDING
+ )
+ storage.saveNote(updatedNote)
+ Logger.d(TAG, " π Marked for re-sync: ${note.title}")
+ }
+
+ Logger.d(TAG, "β
v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
+ }
+
+ // Mark migration as done
+ prefs.edit().putBoolean(migrationKey, true).apply()
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+
+ if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
+ // Settings changed, reload notes
+ viewModel.loadNotes()
+ }
+ }
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+
+ when (requestCode) {
+ REQUEST_NOTIFICATION_PERMISSION -> {
+ if (grantResults.isNotEmpty() &&
+ grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ Toast.makeText(this, "Benachrichtigungen aktiviert", Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(this,
+ "Benachrichtigungen deaktiviert. Du kannst sie in den Einstellungen aktivieren.",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Delete confirmation dialog
+ */
+@Composable
+private fun DeleteConfirmationDialog(
+ noteTitle: String,
+ onDismiss: () -> Unit,
+ onDeleteLocal: () -> Unit,
+ onDeleteFromServer: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Notiz lΓΆschen") },
+ text = {
+ Text("\"$noteTitle\" wird lokal gelΓΆscht.\n\nAuch vom Server lΓΆschen?")
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Abbrechen")
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDeleteLocal) {
+ Text("Nur lokal")
+ }
+ TextButton(onClick = onDeleteFromServer) {
+ Text("Vom Server lΓΆschen")
+ }
+ }
+ )
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt
new file mode 100644
index 0000000..ed35821
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt
@@ -0,0 +1,322 @@
+package dev.dettmer.simplenotes.ui.main
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.ExperimentalMaterial3Api
+// FabPosition nicht mehr benΓΆtigt - FAB wird manuell platziert
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import dev.dettmer.simplenotes.models.NoteType
+import dev.dettmer.simplenotes.sync.SyncStateManager
+import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
+import dev.dettmer.simplenotes.ui.main.components.EmptyState
+import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
+import dev.dettmer.simplenotes.ui.main.components.NotesList
+import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
+import kotlinx.coroutines.launch
+
+/**
+ * Main screen displaying the notes list
+ * v1.5.0: Jetpack Compose MainActivity Redesign
+ *
+ * Performance optimized with proper state handling:
+ * - LazyListState for scroll control
+ * - Scaffold FAB slot for proper z-ordering
+ * - Scroll-to-top on new note
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MainScreen(
+ viewModel: MainViewModel,
+ onOpenNote: (String?) -> Unit,
+ onOpenSettings: () -> Unit,
+ onCreateNote: (NoteType) -> Unit
+) {
+ val notes by viewModel.notes.collectAsState()
+ val syncState by viewModel.syncState.collectAsState()
+ val syncMessage by viewModel.syncMessage.collectAsState()
+ val scrollToTop by viewModel.scrollToTop.collectAsState()
+
+ // Multi-Select State
+ val selectedNotes by viewModel.selectedNotes.collectAsState()
+ val isSelectionMode by viewModel.isSelectionMode.collectAsState()
+
+ // Delete confirmation dialog state
+ var showBatchDeleteDialog by remember { mutableStateOf(false) }
+
+ val snackbarHostState = remember { SnackbarHostState() }
+ val scope = rememberCoroutineScope()
+ val listState = rememberLazyListState()
+
+ // Compute isSyncing once
+ val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
+
+ // Handle snackbar events from ViewModel
+ LaunchedEffect(Unit) {
+ viewModel.showSnackbar.collect { data ->
+ scope.launch {
+ val result = snackbarHostState.showSnackbar(
+ message = data.message,
+ actionLabel = data.actionLabel,
+ duration = SnackbarDuration.Long
+ )
+ if (result == SnackbarResult.ActionPerformed) {
+ data.onAction()
+ }
+ }
+ }
+ }
+
+ // Phase 3: Scroll to top when new note created
+ LaunchedEffect(scrollToTop) {
+ if (scrollToTop) {
+ listState.animateScrollToItem(0)
+ viewModel.resetScrollToTop()
+ }
+ }
+
+ // v1.5.0 Hotfix: FAB manuell mit zIndex platzieren fΓΌr garantierte Sichtbarkeit
+ Scaffold(
+ topBar = {
+ // Animated switch between normal and selection TopBar
+ AnimatedVisibility(
+ visible = isSelectionMode,
+ enter = slideInVertically() + fadeIn(),
+ exit = slideOutVertically() + fadeOut()
+ ) {
+ SelectionTopBar(
+ selectedCount = selectedNotes.size,
+ totalCount = notes.size,
+ onCloseSelection = { viewModel.clearSelection() },
+ onSelectAll = { viewModel.selectAllNotes() },
+ onDeleteSelected = { showBatchDeleteDialog = true }
+ )
+ }
+ AnimatedVisibility(
+ visible = !isSelectionMode,
+ enter = slideInVertically() + fadeIn(),
+ exit = slideOutVertically() + fadeOut()
+ ) {
+ MainTopBar(
+ syncEnabled = !isSyncing,
+ onSyncClick = { viewModel.triggerManualSync("toolbar") },
+ onSettingsClick = onOpenSettings
+ )
+ }
+ },
+ // FAB wird manuell in Box platziert fΓΌr korrekten z-Index
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ containerColor = MaterialTheme.colorScheme.surface
+ ) { paddingValues ->
+ // PullToRefreshBox wraps the content with pull-to-refresh capability
+ PullToRefreshBox(
+ isRefreshing = isSyncing,
+ onRefresh = { viewModel.triggerManualSync("pullToRefresh") },
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Main content column
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Sync Status Banner (not affected by pull-to-refresh)
+ SyncStatusBanner(
+ syncState = syncState,
+ message = syncMessage
+ )
+
+ // Content: Empty state or notes list
+ if (notes.isEmpty()) {
+ EmptyState(modifier = Modifier.weight(1f))
+ } else {
+ NotesList(
+ notes = notes,
+ showSyncStatus = viewModel.isServerConfigured(),
+ selectedNotes = selectedNotes,
+ isSelectionMode = isSelectionMode,
+ listState = listState,
+ modifier = Modifier.weight(1f),
+ onNoteClick = { note -> onOpenNote(note.id) },
+ onNoteLongPress = { note ->
+ // Long-press starts selection mode
+ viewModel.startSelectionMode(note.id)
+ },
+ onNoteSelectionToggle = { note ->
+ viewModel.toggleNoteSelection(note.id)
+ }
+ )
+ }
+ }
+
+ // FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
+ AnimatedVisibility(
+ visible = !isSelectionMode,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp)
+ .zIndex(Float.MAX_VALUE)
+ ) {
+ NoteTypeFAB(
+ onCreateNote = onCreateNote
+ )
+ }
+ }
+ }
+
+ // Batch Delete Confirmation Dialog
+ if (showBatchDeleteDialog) {
+ DeleteConfirmationDialog(
+ noteCount = selectedNotes.size,
+ onDismiss = { showBatchDeleteDialog = false },
+ onDeleteLocal = {
+ viewModel.deleteSelectedNotes(deleteFromServer = false)
+ showBatchDeleteDialog = false
+ },
+ onDeleteEverywhere = {
+ viewModel.deleteSelectedNotes(deleteFromServer = true)
+ showBatchDeleteDialog = false
+ }
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun MainTopBar(
+ syncEnabled: Boolean,
+ onSyncClick: () -> Unit,
+ onSettingsClick: () -> Unit
+) {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Simple Notes",
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ actions = {
+ IconButton(
+ onClick = onSyncClick,
+ enabled = syncEnabled
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = "Synchronisieren"
+ )
+ }
+ IconButton(onClick = onSettingsClick) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Einstellungen"
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface
+ )
+ )
+}
+
+/**
+ * Selection mode TopBar - shows selected count and actions
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SelectionTopBar(
+ selectedCount: Int,
+ totalCount: Int,
+ onCloseSelection: () -> Unit,
+ onSelectAll: () -> Unit,
+ onDeleteSelected: () -> Unit
+) {
+ TopAppBar(
+ navigationIcon = {
+ IconButton(onClick = onCloseSelection) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Auswahl beenden"
+ )
+ }
+ },
+ title = {
+ Text(
+ text = "$selectedCount ausgewΓ€hlt",
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ actions = {
+ // Select All button (only if not all selected)
+ if (selectedCount < totalCount) {
+ IconButton(onClick = onSelectAll) {
+ Icon(
+ imageVector = Icons.Default.SelectAll,
+ contentDescription = "Alle auswΓ€hlen"
+ )
+ }
+ }
+ // Delete button
+ IconButton(
+ onClick = onDeleteSelected,
+ enabled = selectedCount > 0
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "AusgewΓ€hlte lΓΆschen",
+ tint = if (selectedCount > 0) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ }
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ )
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt
new file mode 100644
index 0000000..0ef1b88
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt
@@ -0,0 +1,564 @@
+package dev.dettmer.simplenotes.ui.main
+
+import android.app.Application
+import android.content.Context
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import dev.dettmer.simplenotes.models.Note
+import dev.dettmer.simplenotes.storage.NotesStorage
+import dev.dettmer.simplenotes.sync.SyncStateManager
+import dev.dettmer.simplenotes.sync.WebDavSyncService
+import dev.dettmer.simplenotes.utils.Constants
+import dev.dettmer.simplenotes.utils.Logger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * ViewModel for MainActivity Compose
+ * v1.5.0: Jetpack Compose MainActivity Redesign
+ *
+ * Manages notes list, sync state, and deletion with undo.
+ */
+class MainViewModel(application: Application) : AndroidViewModel(application) {
+
+ companion object {
+ private const val TAG = "MainViewModel"
+ private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
+ private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
+ }
+
+ private val storage = NotesStorage(application)
+ private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Notes State
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private val _notes = MutableStateFlow>(emptyList())
+ val notes: StateFlow> = _notes.asStateFlow()
+
+ private val _pendingDeletions = MutableStateFlow>(emptySet())
+ val pendingDeletions: StateFlow> = _pendingDeletions.asStateFlow()
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Multi-Select State (v1.5.0)
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private val _selectedNotes = MutableStateFlow>(emptySet())
+ val selectedNotes: StateFlow> = _selectedNotes.asStateFlow()
+
+ val isSelectionMode: StateFlow = _selectedNotes
+ .map { it.isNotEmpty() }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Sync State (derived from SyncStateManager)
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
+ val syncState: StateFlow = _syncState.asStateFlow()
+
+ private val _syncMessage = MutableStateFlow(null)
+ val syncMessage: StateFlow = _syncMessage.asStateFlow()
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // UI Events
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private val _showToast = MutableSharedFlow()
+ val showToast: SharedFlow = _showToast.asSharedFlow()
+
+ private val _showDeleteDialog = MutableSharedFlow()
+ val showDeleteDialog: SharedFlow = _showDeleteDialog.asSharedFlow()
+
+ private val _showSnackbar = MutableSharedFlow()
+ val showSnackbar: SharedFlow = _showSnackbar.asSharedFlow()
+
+ // Phase 3: Scroll-to-top when new note is created
+ private val _scrollToTop = MutableStateFlow(false)
+ val scrollToTop: StateFlow = _scrollToTop.asStateFlow()
+
+ // Track first note ID to detect new notes
+ private var previousFirstNoteId: String? = null
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Data Classes
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ data class DeleteDialogData(
+ val note: Note,
+ val originalList: List
+ )
+
+ data class SnackbarData(
+ val message: String,
+ val actionLabel: String,
+ val onAction: () -> Unit
+ )
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Initialization
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ init {
+ // v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
+ viewModelScope.launch(Dispatchers.IO) {
+ loadNotesAsync()
+ }
+ }
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Notes Actions
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Load notes asynchronously on IO dispatcher
+ * This prevents UI blocking during app startup
+ */
+ private suspend fun loadNotesAsync() {
+ val allNotes = storage.loadAllNotes()
+ val pendingIds = _pendingDeletions.value
+ val filteredNotes = allNotes.filter { it.id !in pendingIds }
+
+ withContext(Dispatchers.Main) {
+ // Phase 3: Detect if a new note was added at the top
+ val newFirstNoteId = filteredNotes.firstOrNull()?.id
+ if (newFirstNoteId != null &&
+ previousFirstNoteId != null &&
+ newFirstNoteId != previousFirstNoteId) {
+ // New note at top β trigger scroll
+ _scrollToTop.value = true
+ Logger.d(TAG, "π New note detected at top, triggering scroll-to-top")
+ }
+ previousFirstNoteId = newFirstNoteId
+
+ _notes.value = filteredNotes
+ }
+ }
+
+ /**
+ * Public loadNotes - delegates to async version
+ */
+ fun loadNotes() {
+ viewModelScope.launch(Dispatchers.IO) {
+ loadNotesAsync()
+ }
+ }
+
+ /**
+ * Reset scroll-to-top flag after scroll completed
+ */
+ fun resetScrollToTop() {
+ _scrollToTop.value = false
+ }
+
+ /**
+ * Force scroll to top (e.g., after returning from editor)
+ */
+ fun scrollToTop() {
+ _scrollToTop.value = true
+ }
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Multi-Select Actions (v1.5.0)
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Toggle selection of a note
+ */
+ fun toggleNoteSelection(noteId: String) {
+ _selectedNotes.value = if (noteId in _selectedNotes.value) {
+ _selectedNotes.value - noteId
+ } else {
+ _selectedNotes.value + noteId
+ }
+ }
+
+ /**
+ * Start selection mode with initial note
+ */
+ fun startSelectionMode(noteId: String) {
+ _selectedNotes.value = setOf(noteId)
+ }
+
+ /**
+ * Select all notes
+ */
+ fun selectAllNotes() {
+ _selectedNotes.value = _notes.value.map { it.id }.toSet()
+ }
+
+ /**
+ * Clear selection and exit selection mode
+ */
+ fun clearSelection() {
+ _selectedNotes.value = emptySet()
+ }
+
+ /**
+ * Get count of selected notes
+ */
+ fun getSelectedCount(): Int = _selectedNotes.value.size
+
+ /**
+ * Delete all selected notes
+ */
+ fun deleteSelectedNotes(deleteFromServer: Boolean) {
+ val selectedIds = _selectedNotes.value.toList()
+ val selectedNotes = _notes.value.filter { it.id in selectedIds }
+
+ if (selectedNotes.isEmpty()) return
+
+ // Add to pending deletions
+ _pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
+
+ // Delete from storage
+ selectedNotes.forEach { note ->
+ storage.deleteNote(note.id)
+ }
+
+ // Clear selection
+ clearSelection()
+
+ // Reload notes
+ loadNotes()
+
+ // Show snackbar with undo
+ val count = selectedNotes.size
+ val message = if (deleteFromServer) {
+ "$count Notiz${if (count > 1) "en" else ""} werden vom Server gelΓΆscht"
+ } else {
+ "$count Notiz${if (count > 1) "en" else ""} lokal gelΓΆscht"
+ }
+
+ viewModelScope.launch {
+ _showSnackbar.emit(SnackbarData(
+ message = message,
+ actionLabel = "RΓCKGΓNGIG",
+ onAction = {
+ undoDeleteMultiple(selectedNotes)
+ }
+ ))
+
+ // If delete from server, actually delete after a short delay
+ // (to allow undo action before server deletion)
+ if (deleteFromServer) {
+ kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
+ // Only delete if not restored (check if still in pending)
+ selectedIds.forEach { noteId ->
+ if (noteId in _pendingDeletions.value) {
+ deleteNoteFromServer(noteId)
+ }
+ }
+ } else {
+ // Just finalize local deletion
+ selectedIds.forEach { noteId ->
+ finalizeDeletion(noteId)
+ }
+ }
+ }
+ }
+
+ /**
+ * Undo deletion of multiple notes
+ */
+ private fun undoDeleteMultiple(notes: List) {
+ // Remove from pending deletions
+ _pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
+
+ // Restore to storage
+ notes.forEach { note ->
+ storage.saveNote(note)
+ }
+
+ // Reload notes
+ loadNotes()
+ }
+
+ /**
+ * Called when user long-presses a note to delete
+ * Shows dialog for delete confirmation (replaces swipe-to-delete for performance)
+ */
+ fun onNoteLongPressDelete(note: Note) {
+ val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
+
+ // Store original list for potential restore
+ val originalList = _notes.value.toList()
+
+ if (alwaysDeleteFromServer) {
+ // Auto-delete without dialog
+ deleteNoteConfirmed(note, deleteFromServer = true)
+ } else {
+ // Show dialog - don't remove from UI yet (user can cancel)
+ viewModelScope.launch {
+ _showDeleteDialog.emit(DeleteDialogData(note, originalList))
+ }
+ }
+ }
+
+ /**
+ * Called when user swipes to delete a note (legacy - kept for compatibility)
+ * Shows dialog if "always delete from server" is not enabled
+ */
+ fun onNoteSwipedToDelete(note: Note) {
+ onNoteLongPressDelete(note) // Delegate to long-press handler
+ }
+
+ /**
+ * Restore note after swipe (user cancelled dialog)
+ */
+ fun restoreNoteAfterSwipe(originalList: List) {
+ _notes.value = originalList
+ }
+
+ /**
+ * Confirm note deletion (from dialog or auto-delete)
+ */
+ fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
+ // Add to pending deletions
+ _pendingDeletions.value = _pendingDeletions.value + note.id
+
+ // Delete from storage
+ storage.deleteNote(note.id)
+
+ // Reload notes
+ loadNotes()
+
+ // Show snackbar with undo
+ val message = if (deleteFromServer) {
+ "\"${note.title}\" wird vom Server gelΓΆscht"
+ } else {
+ "\"${note.title}\" lokal gelΓΆscht"
+ }
+
+ viewModelScope.launch {
+ _showSnackbar.emit(SnackbarData(
+ message = message,
+ actionLabel = "RΓCKGΓNGIG",
+ onAction = {
+ undoDelete(note)
+ }
+ ))
+
+ // If delete from server, actually delete after snackbar timeout
+ if (deleteFromServer) {
+ kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
+ // Only delete if not restored (check if still in pending)
+ if (note.id in _pendingDeletions.value) {
+ deleteNoteFromServer(note.id)
+ }
+ } else {
+ // Just finalize local deletion
+ finalizeDeletion(note.id)
+ }
+ }
+ }
+
+ /**
+ * Undo note deletion
+ */
+ fun undoDelete(note: Note) {
+ // Remove from pending deletions
+ _pendingDeletions.value = _pendingDeletions.value - note.id
+
+ // Restore to storage
+ storage.saveNote(note)
+
+ // Reload notes
+ loadNotes()
+ }
+
+ /**
+ * Actually delete note from server after snackbar dismissed
+ */
+ fun deleteNoteFromServer(noteId: String) {
+ viewModelScope.launch {
+ try {
+ val webdavService = WebDavSyncService(getApplication())
+ val success = withContext(Dispatchers.IO) {
+ webdavService.deleteNoteFromServer(noteId)
+ }
+
+ if (success) {
+ _showToast.emit("Vom Server gelΓΆscht")
+ } else {
+ _showToast.emit("Server-LΓΆschung fehlgeschlagen")
+ }
+ } catch (e: Exception) {
+ _showToast.emit("Server-Fehler: ${e.message}")
+ } finally {
+ // Remove from pending deletions
+ _pendingDeletions.value = _pendingDeletions.value - noteId
+ }
+ }
+ }
+
+ /**
+ * Finalize deletion (remove from pending set)
+ */
+ fun finalizeDeletion(noteId: String) {
+ _pendingDeletions.value = _pendingDeletions.value - noteId
+ }
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Sync Actions
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ fun updateSyncState(status: SyncStateManager.SyncStatus) {
+ _syncState.value = status.state
+ _syncMessage.value = status.message
+ }
+
+ /**
+ * Trigger manual sync (from toolbar button or pull-to-refresh)
+ */
+ fun triggerManualSync(source: String = "manual") {
+ if (!SyncStateManager.tryStartSync(source)) {
+ return
+ }
+
+ viewModelScope.launch {
+ try {
+ val syncService = WebDavSyncService(getApplication())
+
+ // Check for unsynced changes
+ if (!syncService.hasUnsyncedChanges()) {
+ Logger.d(TAG, "βοΈ $source Sync: No unsynced changes")
+ SyncStateManager.markCompleted("Bereits synchronisiert")
+ loadNotes()
+ return@launch
+ }
+
+ // Check server reachability
+ val isReachable = withContext(Dispatchers.IO) {
+ syncService.isServerReachable()
+ }
+
+ if (!isReachable) {
+ Logger.d(TAG, "βοΈ $source Sync: Server not reachable")
+ SyncStateManager.markError("Server nicht erreichbar")
+ return@launch
+ }
+
+ // Perform sync
+ val result = withContext(Dispatchers.IO) {
+ syncService.syncNotes()
+ }
+
+ if (result.isSuccess) {
+ SyncStateManager.markCompleted("${result.syncedCount} Notizen")
+ loadNotes()
+ } else {
+ SyncStateManager.markError(result.errorMessage)
+ }
+ } catch (e: Exception) {
+ SyncStateManager.markError(e.message)
+ }
+ }
+ }
+
+ /**
+ * Trigger auto-sync (onResume)
+ * Only runs if server is configured and interval has passed
+ */
+ fun triggerAutoSync(source: String = "auto") {
+ // Throttling check
+ if (!canTriggerAutoSync()) {
+ return
+ }
+
+ // Check if server is configured
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
+ return
+ }
+
+ if (!SyncStateManager.tryStartSync("auto-$source")) {
+ 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()
+
+ viewModelScope.launch {
+ try {
+ val syncService = WebDavSyncService(getApplication())
+
+ // Check for unsynced changes
+ if (!syncService.hasUnsyncedChanges()) {
+ Logger.d(TAG, "βοΈ Auto-sync ($source): No unsynced changes - skipping")
+ SyncStateManager.reset()
+ return@launch
+ }
+
+ // Check server reachability
+ val isReachable = withContext(Dispatchers.IO) {
+ syncService.isServerReachable()
+ }
+
+ if (!isReachable) {
+ Logger.d(TAG, "βοΈ Auto-sync ($source): Server not reachable - skipping silently")
+ SyncStateManager.reset()
+ return@launch
+ }
+
+ // Perform sync
+ val result = withContext(Dispatchers.IO) {
+ syncService.syncNotes()
+ }
+
+ if (result.isSuccess && result.syncedCount > 0) {
+ Logger.d(TAG, "β
Auto-sync successful ($source): ${result.syncedCount} notes")
+ SyncStateManager.markCompleted("${result.syncedCount} Notizen")
+ _showToast.emit("β
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)
+ }
+ } catch (e: Exception) {
+ Logger.e(TAG, "π₯ Auto-sync exception ($source): ${e.message}")
+ SyncStateManager.markError(e.message)
+ }
+ }
+ }
+
+ 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
+ }
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Helpers
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ fun isServerConfigured(): Boolean {
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/DeleteConfirmationDialog.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/DeleteConfirmationDialog.kt
new file mode 100644
index 0000000..5ea14dc
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/DeleteConfirmationDialog.kt
@@ -0,0 +1,93 @@
+package dev.dettmer.simplenotes.ui.main.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+/**
+ * Delete confirmation dialog with server/local options
+ * v1.5.0: Multi-Select Feature
+ */
+@Composable
+fun DeleteConfirmationDialog(
+ noteCount: Int = 1,
+ onDismiss: () -> Unit,
+ onDeleteLocal: () -> Unit,
+ onDeleteEverywhere: () -> Unit
+) {
+ val title = if (noteCount == 1) {
+ "Notiz lΓΆschen?"
+ } else {
+ "$noteCount Notizen lΓΆschen?"
+ }
+
+ val message = if (noteCount == 1) {
+ "Wie mΓΆchtest du diese Notiz lΓΆschen?"
+ } else {
+ "Wie mΓΆchtest du diese $noteCount Notizen lΓΆschen?"
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ confirmButton = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Delete everywhere (server + local) - primary action
+ TextButton(
+ onClick = onDeleteEverywhere,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text("Γberall lΓΆschen (auch Server)")
+ }
+
+ // Delete local only
+ TextButton(
+ onClick = onDeleteLocal,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Nur lokal lΓΆschen")
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Cancel button
+ TextButton(
+ onClick = onDismiss,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Abbrechen")
+ }
+ }
+ },
+ dismissButton = null // All buttons in confirmButton column
+ )
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/EmptyState.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/EmptyState.kt
new file mode 100644
index 0000000..d959c0e
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/EmptyState.kt
@@ -0,0 +1,73 @@
+package dev.dettmer.simplenotes.ui.main.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+/**
+ * Empty state card shown when no notes exist
+ * v1.5.0: Jetpack Compose MainActivity Redesign
+ */
+@Composable
+fun EmptyState(
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Card(
+ modifier = Modifier.padding(horizontal = 32.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Emoji
+ Text(
+ text = "π",
+ fontSize = 64.sp
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Title
+ Text(
+ text = "Noch keine Notizen",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Message
+ Text(
+ text = "Tippe + um eine neue Notiz zu erstellen",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt
new file mode 100644
index 0000000..e48cf77
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt
@@ -0,0 +1,243 @@
+package dev.dettmer.simplenotes.ui.main.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.List
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.outlined.CloudDone
+import androidx.compose.material.icons.outlined.CloudOff
+import androidx.compose.material.icons.outlined.CloudSync
+import androidx.compose.material.icons.outlined.Description
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import dev.dettmer.simplenotes.models.Note
+import dev.dettmer.simplenotes.models.NoteType
+import dev.dettmer.simplenotes.models.SyncStatus
+import dev.dettmer.simplenotes.utils.toReadableTime
+
+/**
+ * Note card - v1.5.0 with Multi-Select Support
+ *
+ * ULTRA SIMPLE + SELECTION:
+ * - NO remember() anywhere
+ * - Direct MaterialTheme access
+ * - Selection indicator via border + checkbox overlay
+ * - Long-press starts selection mode
+ * - Tap in selection mode toggles selection
+ */
+@Composable
+fun NoteCard(
+ note: Note,
+ showSyncStatus: Boolean,
+ isSelected: Boolean = false,
+ isSelectionMode: Boolean = false,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit
+) {
+ val borderColor = if (isSelected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerHigh
+ }
+
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .then(
+ if (isSelected) {
+ Modifier.border(
+ width = 2.dp,
+ color = MaterialTheme.colorScheme.primary,
+ shape = RoundedCornerShape(16.dp)
+ )
+ } else Modifier
+ )
+ .pointerInput(note.id, isSelectionMode) {
+ detectTapGestures(
+ onTap = { onClick() },
+ onLongPress = { onLongClick() }
+ )
+ },
+ shape = RoundedCornerShape(16.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerHigh
+ }
+ )
+ ) {
+ Box {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ // Header row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Type icon
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .background(
+ MaterialTheme.colorScheme.primaryContainer,
+ CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = if (note.noteType == NoteType.TEXT)
+ Icons.Outlined.Description
+ else
+ Icons.AutoMirrored.Outlined.List,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Title
+ Text(
+ text = note.title.ifEmpty { "Ohne Titel" },
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Preview
+ Text(
+ text = when (note.noteType) {
+ NoteType.TEXT -> note.content.take(100)
+ NoteType.CHECKLIST -> {
+ val items = note.checklistItems ?: emptyList()
+ "${items.count { it.isChecked }}/${items.size} erledigt"
+ }
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Footer
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = note.updatedAt.toReadableTime(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.weight(1f)
+ )
+
+ if (showSyncStatus) {
+ Icon(
+ imageVector = when (note.syncStatus) {
+ SyncStatus.SYNCED -> Icons.Outlined.CloudDone
+ SyncStatus.PENDING -> Icons.Outlined.CloudSync
+ SyncStatus.CONFLICT -> Icons.Default.Warning
+ SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
+ },
+ contentDescription = null,
+ tint = when (note.syncStatus) {
+ SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
+ SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.outline
+ },
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+ }
+
+ // Selection indicator checkbox (top-right)
+ androidx.compose.animation.AnimatedVisibility(
+ visible = isSelectionMode,
+ enter = fadeIn() + scaleIn(),
+ exit = fadeOut() + scaleOut(),
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .clip(CircleShape)
+ .background(
+ if (isSelected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerHighest
+ }
+ )
+ .border(
+ width = 2.dp,
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.outline
+ },
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "AusgewΓ€hlt",
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteTypeFAB.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteTypeFAB.kt
new file mode 100644
index 0000000..23b65d4
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteTypeFAB.kt
@@ -0,0 +1,83 @@
+package dev.dettmer.simplenotes.ui.main.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.List
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.outlined.Description
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import dev.dettmer.simplenotes.models.NoteType
+
+/**
+ * FAB with dropdown menu for note type selection
+ * v1.5.0: PERFORMANCE FIX - No Box wrapper for proper elevation
+ *
+ * Uses consistent icons with NoteCard:
+ * - TEXT: Description (document icon)
+ * - CHECKLIST: List (bullet list icon)
+ */
+@Composable
+fun NoteTypeFAB(
+ modifier: Modifier = Modifier,
+ onCreateNote: (NoteType) -> Unit
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ // FAB directly without Box wrapper - elevation works correctly
+ FloatingActionButton(
+ onClick = { expanded = true },
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Neue Notiz"
+ )
+
+ // Dropdown inside FAB - renders as popup overlay
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Text-Notiz") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Outlined.Description,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ },
+ onClick = {
+ expanded = false
+ onCreateNote(NoteType.TEXT)
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Checkliste") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.List,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ },
+ onClick = {
+ expanded = false
+ onCreateNote(NoteType.CHECKLIST)
+ }
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesList.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesList.kt
new file mode 100644
index 0000000..46c4926
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesList.kt
@@ -0,0 +1,65 @@
+package dev.dettmer.simplenotes.ui.main.components
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.dettmer.simplenotes.models.Note
+
+/**
+ * Notes list - v1.5.0 with Multi-Select Support
+ *
+ * ULTRA SIMPLE + SELECTION:
+ * - NO remember() anywhere
+ * - NO caching tricks
+ * - Selection state passed through as parameters
+ * - Tap behavior changes based on selection mode
+ */
+@Composable
+fun NotesList(
+ notes: List,
+ showSyncStatus: Boolean,
+ selectedNotes: Set = emptySet(),
+ isSelectionMode: Boolean = false,
+ modifier: Modifier = Modifier,
+ listState: LazyListState = rememberLazyListState(),
+ onNoteClick: (Note) -> Unit,
+ onNoteLongPress: (Note) -> Unit,
+ onNoteSelectionToggle: (Note) -> Unit = {}
+) {
+ LazyColumn(
+ state = listState,
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp)
+ ) {
+ items(
+ items = notes,
+ key = { it.id },
+ contentType = { "NoteCard" }
+ ) { note ->
+ val isSelected = note.id in selectedNotes
+
+ NoteCard(
+ note = note,
+ showSyncStatus = showSyncStatus,
+ isSelected = isSelected,
+ isSelectionMode = isSelectionMode,
+ onClick = {
+ if (isSelectionMode) {
+ // In selection mode, tap toggles selection
+ onNoteSelectionToggle(note)
+ } else {
+ // Normal mode, open note
+ onNoteClick(note)
+ }
+ },
+ onLongClick = { onNoteLongPress(note) }
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt
new file mode 100644
index 0000000..b72c768
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt
@@ -0,0 +1,70 @@
+package dev.dettmer.simplenotes.ui.main.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.dettmer.simplenotes.sync.SyncStateManager
+
+/**
+ * Sync status banner shown below the toolbar during sync
+ * v1.5.0: Jetpack Compose MainActivity Redesign
+ */
+@Composable
+fun SyncStatusBanner(
+ syncState: SyncStateManager.SyncState,
+ message: String?,
+ modifier: Modifier = Modifier
+) {
+ val isVisible = syncState != SyncStateManager.SyncState.IDLE
+
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ modifier = modifier
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (syncState == SyncStateManager.SyncState.SYNCING) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 3.dp,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Text(
+ text = when (syncState) {
+ SyncStateManager.SyncState.SYNCING -> "Synchronisiere..."
+ SyncStateManager.SyncState.COMPLETED -> message ?: "Synchronisiert"
+ SyncStateManager.SyncState.ERROR -> message ?: "Fehler"
+ SyncStateManager.SyncState.IDLE -> ""
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/ComposeSettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/ComposeSettingsActivity.kt
index 52b3b8b..1569fac 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/ComposeSettingsActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/ComposeSettingsActivity.kt
@@ -3,28 +3,22 @@ package dev.dettmer.simplenotes.ui.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
-import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity
+import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.rememberNavController
import com.google.android.material.color.DynamicColors
import dev.dettmer.simplenotes.SimpleNotesApplication
+import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
import dev.dettmer.simplenotes.utils.Logger
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
@@ -57,11 +51,24 @@ class ComposeSettingsActivity : ComponentActivity() {
// Enable edge-to-edge display
enableEdgeToEdge()
+ // Handle back button with slide animation
+ onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ setResult(RESULT_OK)
+ finish()
+ @Suppress("DEPRECATION")
+ overridePendingTransition(
+ dev.dettmer.simplenotes.R.anim.slide_in_left,
+ dev.dettmer.simplenotes.R.anim.slide_out_right
+ )
+ }
+ })
+
// Collect events from ViewModel (for Activity-level actions)
collectViewModelEvents()
setContent {
- SimpleNotesSettingsTheme {
+ SimpleNotesTheme {
val navController = rememberNavController()
val context = LocalContext.current
@@ -78,6 +85,11 @@ class ComposeSettingsActivity : ComponentActivity() {
onFinish = {
setResult(RESULT_OK)
finish()
+ @Suppress("DEPRECATION")
+ overridePendingTransition(
+ dev.dettmer.simplenotes.R.anim.slide_in_left,
+ dev.dettmer.simplenotes.R.anim.slide_out_right
+ )
}
)
}
@@ -175,34 +187,3 @@ class ComposeSettingsActivity : ComponentActivity() {
}
}
}
-
-/**
- * Material 3 Theme with Dynamic Colors support
- */
-@Composable
-fun SimpleNotesSettingsTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
- dynamicColor: Boolean = true,
- content: @Composable () -> Unit
-) {
- val context = LocalContext.current
-
- val colorScheme = when {
- // Dynamic colors are available on Android 12+
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- if (darkTheme) {
- dynamicDarkColorScheme(context)
- } else {
- dynamicLightColorScheme(context)
- }
- }
- // Fallback to static Material 3 colors
- darkTheme -> darkColorScheme()
- else -> lightColorScheme()
- }
-
- MaterialTheme(
- colorScheme = colorScheme,
- content = content
- )
-}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/theme/SimpleNotesTheme.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/theme/SimpleNotesTheme.kt
new file mode 100644
index 0000000..7564a2c
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/theme/SimpleNotesTheme.kt
@@ -0,0 +1,47 @@
+package dev.dettmer.simplenotes.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+/**
+ * Shared Material 3 Theme with Dynamic Colors (Material You) support
+ * v1.5.0: Unified theme for MainActivity and Settings
+ *
+ * Used by:
+ * - ComposeMainActivity (Notes list)
+ * - ComposeSettingsActivity (Settings screens)
+ */
+@Composable
+fun SimpleNotesTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val context = LocalContext.current
+
+ val colorScheme = when {
+ // Dynamic colors are available on Android 12+
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ if (darkTheme) {
+ dynamicDarkColorScheme(context)
+ } else {
+ dynamicLightColorScheme(context)
+ }
+ }
+ // Fallback to static Material 3 colors
+ darkTheme -> darkColorScheme()
+ else -> lightColorScheme()
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content
+ )
+}
diff --git a/android/app/src/main/res/anim/slide_in_left.xml b/android/app/src/main/res/anim/slide_in_left.xml
new file mode 100644
index 0000000..c9732d1
--- /dev/null
+++ b/android/app/src/main/res/anim/slide_in_left.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/anim/slide_in_right.xml b/android/app/src/main/res/anim/slide_in_right.xml
new file mode 100644
index 0000000..efbb43a
--- /dev/null
+++ b/android/app/src/main/res/anim/slide_in_right.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/anim/slide_out_left.xml b/android/app/src/main/res/anim/slide_out_left.xml
new file mode 100644
index 0000000..2154df4
--- /dev/null
+++ b/android/app/src/main/res/anim/slide_out_left.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/anim/slide_out_right.xml b/android/app/src/main/res/anim/slide_out_right.xml
new file mode 100644
index 0000000..6ab5198
--- /dev/null
+++ b/android/app/src/main/res/anim/slide_out_right.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index ea93011..255babb 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -11,8 +11,8 @@ activity = "1.8.0"
constraintlayout = "2.1.4"
ktlint = "12.1.0"
detekt = "1.23.4"
-# Jetpack Compose v1.5.0
-composeBom = "2024.02.00"
+# Jetpack Compose v1.5.0 - Updated for 120Hz performance
+composeBom = "2026.01.00"
navigationCompose = "2.7.6"
lifecycleRuntimeCompose = "2.7.0"
activityCompose = "1.8.2"