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"