feat(v1.8.0): IMPL_06 Post-Update Changelog Dialog

- Add UpdateChangelogSheet.kt with Material 3 ModalBottomSheet
- Show changelog automatically on first launch after update
- Load changelog from F-Droid metadata via assets (single source of truth)
- Add copyChangelogsToAssets Gradle task (runs before preBuild)
- Copy F-Droid changelogs to /assets/changelogs/{locale}/ at build time
- Store last shown version in SharedPreferences (last_shown_changelog_version)
- Add ClickableText for GitHub CHANGELOG.md link (opens in browser)
- Add update_changelog_title and update_changelog_dismiss strings (EN + DE)
- Add KEY_LAST_SHOWN_CHANGELOG_VERSION constant
- Integrate UpdateChangelogSheet in ComposeMainActivity
- Add Test Mode in Debug Settings with "Reset Changelog Dialog" button
- Add SettingsViewModel.resetChangelogVersion() for testing
- Add test mode strings (debug_test_section, debug_reset_changelog, etc.)
- Update F-Droid changelogs (20.txt) with focus on key features
- Add exception logging in loadChangelog() function
- Add /app/src/main/assets/changelogs/ to .gitignore
- Dialog dismissable via button or swipe gesture
- One-time display per versionCode

Adds post-update changelog dialog with automatic F-Droid changelog reuse.
F-Droid changelogs are the single source of truth for both F-Droid metadata
and in-app display. Gradle task copies changelogs to assets at build time.
Users see localized changelog (DE/EN) based on app language.
This commit is contained in:
inventory69
2026-02-10 16:38:39 +01:00
parent 3e946edafb
commit 661d9e0992
11 changed files with 317 additions and 18 deletions

View File

@@ -199,4 +199,34 @@ detekt {
// Parallel-Verarbeitung für schnellere Checks
parallel = true
}
// 📋 v1.8.0: Copy F-Droid changelogs to assets for post-update dialog
// Single source of truth: F-Droid changelogs are reused in the app
tasks.register<Copy>("copyChangelogsToAssets") {
description = "Copies F-Droid changelogs to app assets for post-update dialog"
from("$rootDir/../fastlane/metadata/android") {
include("*/changelogs/*.txt")
}
into("$projectDir/src/main/assets/changelogs")
// Preserve directory structure: en-US/20.txt, de-DE/20.txt
eachFile {
val parts = relativePath.segments
if (parts.size >= 3) {
// parts[0] = locale (en-US, de-DE)
// parts[1] = "changelogs"
// parts[2] = version file (20.txt)
relativePath = RelativePath(true, parts[0], parts[2])
}
}
includeEmptyDirs = false
}
// Run before preBuild to ensure changelogs are available
tasks.named("preBuild") {
dependsOn("copyChangelogsToAssets")
}

View File

@@ -170,6 +170,9 @@ class ComposeMainActivity : ComponentActivity() {
onOpenSettings = { openSettings() },
onCreateNote = { noteType -> createNote(noteType) }
)
// v1.8.0: Post-Update Changelog (shows once after update)
UpdateChangelogSheet()
}
}
}

View File

@@ -0,0 +1,211 @@
package dev.dettmer.simplenotes.ui.main
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.launch
/**
* v1.8.0: Post-Update Changelog Bottom Sheet
*
* Shows a subtle changelog on first launch after an update.
* - Reads changelog from raw resources (supports DE/EN)
* - Only shows once per versionCode (stored in SharedPreferences)
* - Uses Material 3 ModalBottomSheet with built-in slide-up animation
* - Dismissable via button or swipe-down
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateChangelogSheet() {
val context = LocalContext.current
val prefs = remember {
context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
val currentVersionCode = BuildConfig.VERSION_CODE
val lastShownVersion = prefs.getInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
// Only show if this is a new version
var showSheet by remember { mutableStateOf(currentVersionCode > lastShownVersion) }
if (!showSheet) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
// Load changelog text based on current locale
val changelogText = remember {
loadChangelog(context)
}
ModalBottomSheet(
onDismissRequest = {
showSheet = false
prefs.edit()
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, currentVersionCode)
.apply()
},
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 2.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Title
Text(
text = stringResource(R.string.update_changelog_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
// Changelog content with clickable links
val annotatedText = buildAnnotatedString {
val lines = changelogText.split("\n")
lines.forEachIndexed { index, line ->
if (line.startsWith("http://") || line.startsWith("https://")) {
// Make URLs clickable
pushStringAnnotation(
tag = "URL",
annotation = line.trim()
)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
)
) {
append(line)
}
pop()
} else {
append(line)
}
if (index < lines.size - 1) append("\n")
}
}
ClickableText(
text = annotatedText,
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.fillMaxWidth(),
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item))
context.startActivity(intent)
}
}
)
Spacer(modifier = Modifier.height(24.dp))
// Dismiss button
Button(
onClick = {
scope.launch {
sheetState.hide()
}.invokeOnCompletion {
showSheet = false
prefs.edit()
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, currentVersionCode)
.apply()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
) {
Text(stringResource(R.string.update_changelog_dismiss))
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
/**
* Load changelog text from assets based on current app locale and versionCode.
* Changelogs are copied from /fastlane/metadata/android/{locale}/changelogs/{versionCode}.txt
* at build time, providing a single source of truth for F-Droid and in-app display.
* Falls back to English if the localized version is not available.
*/
private fun loadChangelog(context: Context): String {
val currentLocale = AppCompatDelegate.getApplicationLocales()
val languageCode = if (currentLocale.isEmpty) {
// System default — check system locale
java.util.Locale.getDefault().language
} else {
currentLocale.get(0)?.language ?: "en"
}
// Map language code to F-Droid locale directory
val localeDir = when (languageCode) {
"de" -> "de-DE"
else -> "en-US"
}
val versionCode = BuildConfig.VERSION_CODE
val changelogPath = "changelogs/$localeDir/$versionCode.txt"
return try {
context.assets.open(changelogPath)
.bufferedReader()
.use { it.readText() }
} catch (e: Exception) {
Logger.e("UpdateChangelogSheet", "Failed to load changelog for locale: $localeDir", e)
// Fallback to English
try {
context.assets.open("changelogs/en-US/$versionCode.txt")
.bufferedReader()
.use { it.readText() }
} catch (e2: Exception) {
Logger.e("UpdateChangelogSheet", "Failed to load English fallback changelog", e2)
"v${BuildConfig.VERSION_NAME}"
}
}
}

View File

@@ -811,6 +811,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun getLogFile() = Logger.getLogFile(getApplication())
/**
* v1.8.0: Reset changelog version to force showing the changelog dialog on next start
* Used for testing the post-update changelog feature
*/
fun resetChangelogVersion() {
prefs.edit()
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
.apply()
}
// ═══════════════════════════════════════════════════════════════════════
// Helper
// ═══════════════════════════════════════════════════════════════════════

View File

@@ -119,6 +119,31 @@ fun DebugSettingsScreen(
)
Spacer(modifier = Modifier.height(16.dp))
SettingsDivider()
// v1.8.0: Test Mode Section
SettingsSectionHeader(text = stringResource(R.string.debug_test_section))
Spacer(modifier = Modifier.height(8.dp))
// Info about test mode
SettingsInfoCard(
text = stringResource(R.string.debug_reset_changelog_desc)
)
val changelogResetToast = stringResource(R.string.debug_changelog_reset)
SettingsButton(
text = stringResource(R.string.debug_reset_changelog),
onClick = {
viewModel.resetChangelogVersion()
android.widget.Toast.makeText(context, changelogResetToast, android.widget.Toast.LENGTH_SHORT).show()
},
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
}

View File

@@ -79,4 +79,7 @@ object Constants {
const val KEY_SORT_DIRECTION = "sort_direction"
const val DEFAULT_SORT_OPTION = "updatedAt"
const val DEFAULT_SORT_DIRECTION = "desc"
// 📋 v1.8.0: Post-Update Changelog
const val KEY_LAST_SHOWN_CHANGELOG_VERSION = "last_shown_changelog_version"
}

View File

@@ -403,6 +403,10 @@
<string name="debug_delete_logs">🗑️ Logs löschen</string>
<string name="debug_delete_logs_title">Logs löschen?</string>
<string name="debug_delete_logs_message">Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.</string>
<string name="debug_test_section">Test-Modus</string>
<string name="debug_reset_changelog">Changelog-Dialog zurücksetzen</string>
<string name="debug_reset_changelog_desc">Changelog beim nächsten App-Start anzeigen</string>
<string name="debug_changelog_reset">Changelog wird beim nächsten Start angezeigt</string>
<!-- Legacy -->
<string name="file_logging_privacy_notice"> Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
@@ -443,6 +447,10 @@
<string name="about_changelog_title">Changelog</string>
<string name="about_changelog_subtitle">Vollständige Versionshistorie</string>
<!-- v1.8.0: Post-Update Changelog Dialog -->
<string name="update_changelog_title">Was ist neu</string>
<string name="update_changelog_dismiss">Alles klar!</string>
<!-- v1.8.0: Backup Progress -->
<string name="backup_progress_creating">Backup wird erstellt…</string>
<string name="backup_progress_restoring">Backup wird wiederhergestellt…</string>

View File

@@ -403,6 +403,10 @@
<string name="debug_delete_logs">🗑️ Delete Logs</string>
<string name="debug_delete_logs_title">Delete logs?</string>
<string name="debug_delete_logs_message">All saved sync logs will be permanently deleted.</string>
<string name="debug_test_section">Test Mode</string>
<string name="debug_reset_changelog">Reset Changelog Dialog</string>
<string name="debug_reset_changelog_desc">Show changelog on next app start</string>
<string name="debug_changelog_reset">Changelog will show on next start</string>
<!-- Legacy -->
<string name="file_logging_privacy_notice"> Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time.</string>
@@ -443,6 +447,10 @@
<string name="about_changelog_title">Changelog</string>
<string name="about_changelog_subtitle">Full version history</string>
<!-- v1.8.0: Post-Update Changelog Dialog -->
<string name="update_changelog_title">What\'s New</string>
<string name="update_changelog_dismiss">Got it!</string>
<!-- v1.8.0: Backup Progress -->
<string name="backup_progress_creating">Creating backup…</string>
<string name="backup_progress_restoring">Restoring backup…</string>