fix(v1.5.0): Silent-Sync Mode + UI Improvements

Silent-Sync Implementation (Auto-Sync Banner Fix):
- Add SYNCING_SILENT state to SyncStateManager for background syncs
- Auto-sync (onResume) now triggers silently without banner interruption
- Silent-sync state blocks additional manual syncs (mutual exclusion)
- Error banners still display even after silent-sync failures
- SyncStatus tracks 'silent' flag to hide COMPLETED banners after silent-sync

UI/UX Improvements (from v1.5.0 post-migration fixes):
- Fix text wrapping in checklist items (singleLine=false, maxLines=5)
- Fix cursor position in text notes (use TextFieldValue with TextRange)
- Display app icon instead of emoji in AboutScreen
- Add smooth slide animations for NoteEditor transitions
- Remove visual noise from AboutScreen icon

Technical Changes:
- ComposeNoteEditorActivity: Add back animation with OnBackPressedCallback
- ComposeMainActivity: Add entry/exit slide animations for note editing
- NoteEditorScreen: Use TextFieldValue for proper cursor positioning
- ChecklistItemRow: Enable text wrapping for long checklist items
- AboutScreen: Convert Drawable to Bitmap via Canvas (supports AdaptiveIcon)
- SyncStatusBanner: Exclude SYNCING_SILENT from visibility checks
- MainActivity: Update legacy auto-sync to use silent mode

Fixes #[auto-sync-banner], improves #[user-experience]

Branch: feature/v1.5.0
This commit is contained in:
inventory69
2026-01-15 22:08:00 +01:00
parent 20ec5ba9f9
commit 3ada6c966d
9 changed files with 146 additions and 27 deletions

View File

@@ -182,6 +182,11 @@ class MainActivity : AppCompatActivity() {
swipeRefreshLayout.isRefreshing = false swipeRefreshLayout.isRefreshing = false
syncStatusBanner.visibility = View.GONE syncStatusBanner.visibility = View.GONE
} }
// v1.5.0: Silent-Sync - Banner nicht anzeigen, aber Sync-Controls deaktivieren
SyncStateManager.SyncState.SYNCING_SILENT -> {
setSyncControlsEnabled(false)
// Kein Banner anzeigen bei Silent-Sync (z.B. onResume Auto-Sync)
}
} }
} }
} }
@@ -222,6 +227,7 @@ class MainActivity : AppCompatActivity() {
* - Nur Success-Toast (kein "Auto-Sync..." Toast) * - Nur Success-Toast (kein "Auto-Sync..." Toast)
* *
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!) * NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
*/ */
private fun triggerAutoSync(source: String = "unknown") { private fun triggerAutoSync(source: String = "unknown") {
// Throttling: Max 1 Sync pro Minute // Throttling: Max 1 Sync pro Minute
@@ -230,7 +236,8 @@ class MainActivity : AppCompatActivity() {
} }
// 🔄 v1.3.1: Check if sync already running // 🔄 v1.3.1: Check if sync already running
if (!SyncStateManager.tryStartSync("auto-$source")) { // v1.5.0: silent=true - kein Banner bei Auto-Sync
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress") Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return return
} }

View File

@@ -19,7 +19,8 @@ object SyncStateManager {
*/ */
enum class SyncState { enum class SyncState {
IDLE, // Kein Sync aktiv IDLE, // Kein Sync aktiv
SYNCING, // Sync läuft gerade SYNCING, // Sync läuft gerade (Banner sichtbar)
SYNCING_SILENT, // v1.5.0: Sync läuft im Hintergrund (kein Banner, z.B. onResume)
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen) COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
ERROR // Sync fehlgeschlagen (kurz anzeigen) ERROR // Sync fehlgeschlagen (kurz anzeigen)
} }
@@ -31,6 +32,7 @@ object SyncStateManager {
val state: SyncState = SyncState.IDLE, val state: SyncState = SyncState.IDLE,
val message: String? = null, val message: String? = null,
val source: String? = null, // "manual", "auto", "pullToRefresh", "background" val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
val silent: Boolean = false, // v1.5.0: Wenn true, wird nach Completion kein Banner angezeigt
val timestamp: Long = System.currentTimeMillis() val timestamp: Long = System.currentTimeMillis()
) )
@@ -44,28 +46,35 @@ object SyncStateManager {
private val lock = Any() private val lock = Any()
/** /**
* Prüft ob gerade ein Sync läuft * Prüft ob gerade ein Sync läuft (inkl. Silent-Sync)
*/ */
val isSyncing: Boolean val isSyncing: Boolean
get() = _syncStatus.value?.state == SyncState.SYNCING get() {
val state = _syncStatus.value?.state
return state == SyncState.SYNCING || state == SyncState.SYNCING_SILENT
}
/** /**
* Versucht einen Sync zu starten. * Versucht einen Sync zu starten.
* @param source Quelle des Syncs (für Logging)
* @param silent v1.5.0: Wenn true, wird kein Banner angezeigt (z.B. bei onResume Auto-Sync)
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft * @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
*/ */
fun tryStartSync(source: String): Boolean { fun tryStartSync(source: String, silent: Boolean = false): Boolean {
synchronized(lock) { synchronized(lock) {
if (isSyncing) { if (isSyncing) {
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source") Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
return false return false
} }
Logger.d(TAG, "🔄 Starting sync from: $source") val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING
Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)")
_syncStatus.postValue( _syncStatus.postValue(
SyncStatus( SyncStatus(
state = SyncState.SYNCING, state = syncState,
message = "Synchronisiere...", message = "Synchronisiere...",
source = source source = source,
silent = silent // v1.5.0: Merkt sich ob silent für markCompleted()
) )
) )
return true return true
@@ -74,18 +83,29 @@ object SyncStateManager {
/** /**
* Markiert Sync als erfolgreich abgeschlossen * Markiert Sync als erfolgreich abgeschlossen
* v1.5.0: Bei Silent-Sync direkt auf IDLE wechseln (kein Banner)
*/ */
fun markCompleted(message: String? = null) { fun markCompleted(message: String? = null) {
synchronized(lock) { synchronized(lock) {
val currentSource = _syncStatus.value?.source val current = _syncStatus.value
Logger.d(TAG, "✅ Sync completed from: $currentSource") val currentSource = current?.source
_syncStatus.postValue( val wasSilent = current?.silent == true
SyncStatus(
state = SyncState.COMPLETED, Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)")
message = message,
source = currentSource if (wasSilent) {
// v1.5.0: Silent-Sync - direkt auf IDLE, kein Banner anzeigen
_syncStatus.postValue(SyncStatus())
} else {
// Normaler Sync - COMPLETED State anzeigen
_syncStatus.postValue(
SyncStatus(
state = SyncState.COMPLETED,
message = message,
source = currentSource
)
) )
) }
} }
} }

View File

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.editor
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
@@ -48,11 +49,30 @@ class ComposeNoteEditorActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
// v1.5.0: Handle back button with slide animation
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
finish()
@Suppress("DEPRECATION")
overridePendingTransition(
dev.dettmer.simplenotes.R.anim.slide_in_left,
dev.dettmer.simplenotes.R.anim.slide_out_right
)
}
})
setContent { setContent {
SimpleNotesTheme { SimpleNotesTheme {
NoteEditorScreen( NoteEditorScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { finish() } onNavigateBack = {
finish()
@Suppress("DEPRECATION")
overridePendingTransition(
dev.dettmer.simplenotes.R.anim.slide_in_left,
dev.dettmer.simplenotes.R.anim.slide_out_right
)
}
) )
} }
} }

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -43,6 +44,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -252,9 +255,30 @@ private fun TextNoteContent(
focusRequester: FocusRequester, focusRequester: FocusRequester,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// v1.5.0: Use TextFieldValue to control cursor position
var textFieldValue by remember(content) {
mutableStateOf(TextFieldValue(
text = content,
selection = TextRange(content.length)
))
}
// Sync external changes
LaunchedEffect(content) {
if (textFieldValue.text != content) {
textFieldValue = TextFieldValue(
text = content,
selection = TextRange(content.length)
)
}
}
OutlinedTextField( OutlinedTextField(
value = content, value = textFieldValue,
onValueChange = onContentChange, onValueChange = { newValue ->
textFieldValue = newValue
onContentChange(newValue.text)
},
modifier = modifier.focusRequester(focusRequester), modifier = modifier.focusRequester(focusRequester),
label = { Text(stringResource(R.string.content)) }, label = { Text(stringResource(R.string.content)) },
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(16.dp)

View File

@@ -142,7 +142,8 @@ fun ChecklistItemRow(
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onNext = { onAddNewItem() } onNext = { onAddNewItem() }
), ),
singleLine = true, singleLine = false,
maxLines = 5,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
Box { Box {

View File

@@ -234,14 +234,28 @@ class ComposeMainActivity : ComponentActivity() {
noteId?.let { noteId?.let {
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it) intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
} }
startActivity(intent)
// v1.5.0: Add slide animation
val options = ActivityOptions.makeCustomAnimation(
this,
dev.dettmer.simplenotes.R.anim.slide_in_right,
dev.dettmer.simplenotes.R.anim.slide_out_left
)
startActivity(intent, options.toBundle())
} }
private fun createNote(noteType: NoteType) { private fun createNote(noteType: NoteType) {
cameFromEditor = true cameFromEditor = true
val intent = Intent(this, ComposeNoteEditorActivity::class.java) val intent = Intent(this, ComposeNoteEditorActivity::class.java)
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name) intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent)
// v1.5.0: Add slide animation
val options = ActivityOptions.makeCustomAnimation(
this,
dev.dettmer.simplenotes.R.anim.slide_in_right,
dev.dettmer.simplenotes.R.anim.slide_out_left
)
startActivity(intent, options.toBundle())
} }
private fun openSettings() { private fun openSettings() {

View File

@@ -470,6 +470,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* Trigger auto-sync (onResume) * Trigger auto-sync (onResume)
* Only runs if server is configured and interval has passed * Only runs if server is configured and interval has passed
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
*/ */
fun triggerAutoSync(source: String = "auto") { fun triggerAutoSync(source: String = "auto") {
// Throttling check // Throttling check
@@ -483,7 +484,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
if (!SyncStateManager.tryStartSync("auto-$source")) { // v1.5.0: silent=true - kein Banner bei Auto-Sync, aber Fehler werden trotzdem angezeigt
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress") Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return return
} }

View File

@@ -22,6 +22,7 @@ import dev.dettmer.simplenotes.sync.SyncStateManager
/** /**
* Sync status banner shown below the toolbar during sync * Sync status banner shown below the toolbar during sync
* v1.5.0: Jetpack Compose MainActivity Redesign * v1.5.0: Jetpack Compose MainActivity Redesign
* v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen
*/ */
@Composable @Composable
fun SyncStatusBanner( fun SyncStatusBanner(
@@ -29,7 +30,10 @@ fun SyncStatusBanner(
message: String?, message: String?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// v1.5.0: Banner nicht anzeigen bei IDLE oder SYNCING_SILENT (Auto-Sync im Hintergrund)
// Fehler werden trotzdem angezeigt (ERROR state nach Silent-Sync wechselt zu ERROR, nicht SYNCING_SILENT)
val isVisible = syncState != SyncStateManager.SyncState.IDLE val isVisible = syncState != SyncStateManager.SyncState.IDLE
&& syncState != SyncStateManager.SyncState.SYNCING_SILENT
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = isVisible,
@@ -57,6 +61,7 @@ fun SyncStatusBanner(
Text( Text(
text = when (syncState) { text = when (syncState) {
SyncStateManager.SyncState.SYNCING -> "Synchronisiere..." SyncStateManager.SyncState.SYNCING -> "Synchronisiere..."
SyncStateManager.SyncState.SYNCING_SILENT -> "" // v1.5.0: Wird nicht angezeigt (isVisible = false)
SyncStateManager.SyncState.COMPLETED -> message ?: "Synchronisiert" SyncStateManager.SyncState.COMPLETED -> message ?: "Synchronisiert"
SyncStateManager.SyncState.ERROR -> message ?: "Fehler" SyncStateManager.SyncState.ERROR -> message ?: "Fehler"
SyncStateManager.SyncState.IDLE -> "" SyncStateManager.SyncState.IDLE -> ""

View File

@@ -1,8 +1,13 @@
package dev.dettmer.simplenotes.ui.settings.screens package dev.dettmer.simplenotes.ui.settings.screens
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -13,6 +18,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -25,11 +31,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
@@ -76,9 +85,26 @@ fun AboutScreen(
.padding(24.dp), .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( // v1.5.0: App icon loaded from PackageManager and converted to Bitmap
text = "📝", val context = LocalContext.current
style = MaterialTheme.typography.displayMedium val appIcon = remember {
val drawable = context.packageManager.getApplicationIcon(context.packageName)
// Convert any Drawable (including AdaptiveIconDrawable) to Bitmap
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth.coerceAtLeast(1),
drawable.intrinsicHeight.coerceAtLeast(1),
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
bitmap.asImageBitmap()
}
Image(
bitmap = appIcon,
contentDescription = "App Icon",
modifier = Modifier.size(96.dp)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))