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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -142,7 +142,8 @@ fun ChecklistItemRow(
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { onAddNewItem() }
|
||||
),
|
||||
singleLine = true,
|
||||
singleLine = false,
|
||||
maxLines = 5,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
val isVisible = syncState != SyncStateManager.SyncState.IDLE
|
||||
// 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 -> ""
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user