feat(v1.5.0): Complete MainActivity Jetpack Compose redesign
## Major Changes
### New Jetpack Compose Architecture (1,883 LOC total)
- ComposeMainActivity.kt (370L): Main activity with Compose integration
- MainScreen.kt (322L): Root Compose screen with layout orchestration
- MainViewModel.kt (564L): MVVM state management for notes and sync
- components/NoteCard.kt (243L): Individual note card with selection support
- components/NotesList.kt (65L): LazyColumn with optimized item rendering
- components/DeleteConfirmationDialog.kt (93L): Dialog with Server/Local options
- components/EmptyState.kt (73L): Empty state UI
- components/NoteTypeFAB.kt (83L): Floating action button with note type menu
- components/SyncStatusBanner.kt (70L): Sync status indicator banner
### Material 3 & Design System
- SimpleNotesTheme.kt: Material 3 theme with Dynamic Colors (Material You)
- Full Material 3 color scheme implementation
- Dynamic color support for Android 12+ (Material You)
- Consistent design across MainActivity and SettingsActivity
### Performance Optimizations
- Upgraded Compose BOM: 2024.12.01 → 2026.01.00 (latest Jan 2026)
- Enable Strong Skipping Mode for efficient recomposition
- Async loadNotes() on IO dispatcher (no UI blocking at startup)
- LazyColumn with proper key={it.id} and contentType
- Pull-to-refresh with PullToRefreshBox
- Minimal recomposition with state separation
### Multi-Select Feature (v1.5.0)
- Long-press note to enter selection mode
- Tap additional notes to toggle selection in selection mode
- SelectionTopBar with:
* Selection counter ("X selected")
* Select All button
* Batch Delete button
- Animated checkbox indicator on selected note cards
- DeleteConfirmationDialog with Server/Local deletion options
- Fixed server deletion: deleteNoteFromServer() now properly called with 3.5s delay
### Dependency Updates
- Added org.jetbrains.kotlin.plugin.compose (Compose Compiler)
- Jetpack Compose libraries:
* androidx.compose.bom:2026.01.00
* androidx.compose.ui, material3, material-icons-extended
* androidx.activity-compose, androidx.navigation-compose
* androidx.lifecycle-runtime-compose
- All dependencies remain Apache 2.0 licensed (100% FOSS)
### Animation & Transitions
- New animation resources:
* slide_in_right.xml, slide_out_left.xml
* slide_in_left.xml, slide_out_right.xml
- Settings slide animations (left/right navigation)
- Selection mode TopBar transitions (fade + slide)
- Smooth selection checkbox appearance
### Backward Compatibility
- NoteEditorActivity (XML) still used (kept for compatibility)
- Existing database and sync functionality unchanged
- Smooth migration path for future Compose editor
### Bug Fixes
- Server deletion now executes after snackbar timeout (3.5s)
- Multi-select batch deletion with undo support
- FAB z-index fixed to ensure visibility above all content
- Scroll-to-top animation on new note creation
### Code Quality
- Removed 805-line legacy MainActivity.kt
- Clean separation of concerns: Activity → Screen → ViewModel
- Composable functions follow Material 3 guidelines
- No remember() blocks inside LazyColumn items (performance)
- Direct MaterialTheme access (Compose handles optimization)
### Manifest Changes
- Updated to use ComposeMainActivity as main launcher
- Activity transition animations configured
### Testing
- Build successful on Pixel 9 Pro XL (Android 16, 120Hz)
- Release build optimized (minified + shrinkResources)
- Multi-select UX tested
- Server deletion verified
BREAKING CHANGE: Long-press on note now enters multi-select mode instead of direct delete
RELATED: v1.5.0_EXTENDED_FEATURES_PLAN.md added to project-docs
This commit is contained in:
@@ -100,6 +100,11 @@ android {
|
||||
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
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
|
||||
@@ -27,8 +27,9 @@
|
||||
android:theme="@style/Theme.SimpleNotes"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="31">
|
||||
<!-- MainActivity v1.5.0 (Jetpack Compose) - Launcher -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".ui.main.ComposeMainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.SimpleNotes.Splash">
|
||||
<intent-filter>
|
||||
@@ -37,21 +38,27 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Legacy MainActivity (XML-based) - kept for reference -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SimpleNotes" />
|
||||
|
||||
<!-- Editor Activity -->
|
||||
<activity
|
||||
android:name=".NoteEditorActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
android:parentActivityName=".ui.main.ComposeMainActivity" />
|
||||
|
||||
<!-- Settings Activity (Legacy - XML-based) -->
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
android:parentActivityName=".ui.main.ComposeMainActivity" />
|
||||
|
||||
<!-- Settings Activity v1.5.0 (Jetpack Compose) -->
|
||||
<activity
|
||||
android:name=".ui.settings.ComposeSettingsActivity"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:parentActivityName=".ui.main.ComposeMainActivity"
|
||||
android:theme="@style/Theme.SimpleNotes" />
|
||||
|
||||
<!-- Boot Receiver - Startet WorkManager nach Reboot -->
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MainViewModel.DeleteDialogData?>(null) }
|
||||
|
||||
// Handle delete dialog events
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showDeleteDialog.collect { data ->
|
||||
deleteDialogData = data
|
||||
}
|
||||
}
|
||||
|
||||
// Handle toast events
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showToast.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
deleteDialogData?.let { data ->
|
||||
DeleteConfirmationDialog(
|
||||
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<out String>,
|
||||
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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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<List<Note>>(emptyList())
|
||||
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
||||
|
||||
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
|
||||
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Multi-Select State (v1.5.0)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
|
||||
|
||||
val isSelectionMode: StateFlow<Boolean> = _selectedNotes
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Sync State (derived from SyncStateManager)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
|
||||
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
|
||||
|
||||
private val _syncMessage = MutableStateFlow<String?>(null)
|
||||
val syncMessage: StateFlow<String?> = _syncMessage.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// UI Events
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _showToast = MutableSharedFlow<String>()
|
||||
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
||||
|
||||
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
|
||||
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
|
||||
|
||||
private val _showSnackbar = MutableSharedFlow<SnackbarData>()
|
||||
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
|
||||
|
||||
// Phase 3: Scroll-to-top when new note is created
|
||||
private val _scrollToTop = MutableStateFlow(false)
|
||||
val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow()
|
||||
|
||||
// Track first note ID to detect new notes
|
||||
private var previousFirstNoteId: String? = null
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Data Classes
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
data class DeleteDialogData(
|
||||
val note: Note,
|
||||
val originalList: List<Note>
|
||||
)
|
||||
|
||||
data class SnackbarData(
|
||||
val message: String,
|
||||
val actionLabel: String,
|
||||
val onAction: () -> Unit
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Initialization
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
init {
|
||||
// v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
loadNotesAsync()
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Notes Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Load notes asynchronously on IO dispatcher
|
||||
* This prevents UI blocking during app startup
|
||||
*/
|
||||
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<Note>) {
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
|
||||
|
||||
// Restore to storage
|
||||
notes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Note>) {
|
||||
_notes.value = originalList
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm note deletion (from dialog or auto-delete)
|
||||
*/
|
||||
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
|
||||
// Add to pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value + note.id
|
||||
|
||||
// Delete from storage
|
||||
storage.deleteNote(note.id)
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
|
||||
// Show snackbar with undo
|
||||
val message = if (deleteFromServer) {
|
||||
"\"${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://"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Note>,
|
||||
showSyncStatus: Boolean,
|
||||
selectedNotes: Set<String> = 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
9
android/app/src/main/res/anim/slide_in_left.xml
Normal file
9
android/app/src/main/res/anim/slide_in_left.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Slide in from left - Main screen return animation -->
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate">
|
||||
<translate
|
||||
android:fromXDelta="-100%"
|
||||
android:toXDelta="0%"
|
||||
android:duration="300" />
|
||||
</set>
|
||||
9
android/app/src/main/res/anim/slide_in_right.xml
Normal file
9
android/app/src/main/res/anim/slide_in_right.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Slide in from right - Settings opening animation -->
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate">
|
||||
<translate
|
||||
android:fromXDelta="100%"
|
||||
android:toXDelta="0%"
|
||||
android:duration="300" />
|
||||
</set>
|
||||
9
android/app/src/main/res/anim/slide_out_left.xml
Normal file
9
android/app/src/main/res/anim/slide_out_left.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Slide out to left - Main screen exit when opening Settings -->
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate">
|
||||
<translate
|
||||
android:fromXDelta="0%"
|
||||
android:toXDelta="-100%"
|
||||
android:duration="300" />
|
||||
</set>
|
||||
9
android/app/src/main/res/anim/slide_out_right.xml
Normal file
9
android/app/src/main/res/anim/slide_out_right.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Slide out to right - Settings closing animation -->
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate">
|
||||
<translate
|
||||
android:fromXDelta="0%"
|
||||
android:toXDelta="100%"
|
||||
android:duration="300" />
|
||||
</set>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user