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
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)
*
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
*/
private fun triggerAutoSync(source: String = "unknown") {
// Throttling: Max 1 Sync pro Minute
@@ -230,7 +236,8 @@ class MainActivity : AppCompatActivity() {
}
// 🔄 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")
return
}

View File

@@ -19,7 +19,8 @@ object SyncStateManager {
*/
enum class SyncState {
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)
ERROR // Sync fehlgeschlagen (kurz anzeigen)
}
@@ -31,6 +32,7 @@ object SyncStateManager {
val state: SyncState = SyncState.IDLE,
val message: String? = null,
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()
)
@@ -44,28 +46,35 @@ object SyncStateManager {
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
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.
* @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
*/
fun tryStartSync(source: String): Boolean {
fun tryStartSync(source: String, silent: Boolean = false): Boolean {
synchronized(lock) {
if (isSyncing) {
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
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(
state = SyncState.SYNCING,
state = syncState,
message = "Synchronisiere...",
source = source
source = source,
silent = silent // v1.5.0: Merkt sich ob silent für markCompleted()
)
)
return true
@@ -74,18 +83,29 @@ object SyncStateManager {
/**
* Markiert Sync als erfolgreich abgeschlossen
* v1.5.0: Bei Silent-Sync direkt auf IDLE wechseln (kein Banner)
*/
fun markCompleted(message: String? = null) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
Logger.d(TAG, "✅ Sync completed from: $currentSource")
_syncStatus.postValue(
SyncStatus(
state = SyncState.COMPLETED,
message = message,
source = currentSource
val current = _syncStatus.value
val currentSource = current?.source
val wasSilent = current?.silent == true
Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)")
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 androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
@@ -48,11 +49,30 @@ class ComposeNoteEditorActivity : ComponentActivity() {
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 {
SimpleNotesTheme {
NoteEditorScreen(
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -43,6 +44,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
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.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -252,9 +255,30 @@ private fun TextNoteContent(
focusRequester: FocusRequester,
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(
value = content,
onValueChange = onContentChange,
value = textFieldValue,
onValueChange = { newValue ->
textFieldValue = newValue
onContentChange(newValue.text)
},
modifier = modifier.focusRequester(focusRequester),
label = { Text(stringResource(R.string.content)) },
shape = RoundedCornerShape(16.dp)

View File

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

View File

@@ -234,14 +234,28 @@ class ComposeMainActivity : ComponentActivity() {
noteId?.let {
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) {
cameFromEditor = true
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
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() {

View File

@@ -470,6 +470,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
/**
* Trigger auto-sync (onResume)
* 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") {
// Throttling check
@@ -483,7 +484,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
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")
return
}

View File

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

View File

@@ -1,8 +1,13 @@
package dev.dettmer.simplenotes.ui.settings.screens
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
@@ -76,9 +85,26 @@ fun AboutScreen(
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "📝",
style = MaterialTheme.typography.displayMedium
// v1.5.0: App icon loaded from PackageManager and converted to Bitmap
val context = LocalContext.current
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))