@@ -9,7 +9,10 @@ import android.annotation.SuppressLint
99import android.graphics.Color.argb
1010import android.graphics.Color.colorToHSV
1111import android.net.Uri
12- import androidx.compose.animation.*
12+ import androidx.compose.animation.AnimatedContent
13+ import androidx.compose.animation.AnimatedVisibility
14+ import androidx.compose.animation.Crossfade
15+ import androidx.compose.animation.ExperimentalAnimationApi
1316import androidx.compose.animation.core.animateFloatAsState
1417import androidx.compose.animation.core.spring
1518import androidx.compose.animation.core.tween
@@ -18,6 +21,7 @@ import androidx.compose.foundation.clickable
1821import androidx.compose.foundation.layout.*
1922import androidx.compose.foundation.lazy.LazyColumn
2023import androidx.compose.foundation.lazy.items
24+ import androidx.compose.foundation.lazy.itemsIndexed
2125import androidx.compose.foundation.shape.CircleShape
2226import androidx.compose.foundation.shape.RoundedCornerShape
2327import androidx.compose.foundation.text.KeyboardActions
@@ -54,12 +58,16 @@ import app.morphe.manager.domain.repository.PatchBundleRepository
5458import app.morphe.manager.patcher.patch.PatchInfo
5559import app.morphe.manager.ui.screen.shared.*
5660import app.morphe.manager.util.*
61+ import com.mikepenz.markdown.model.parseMarkdownFlow
5762import compose.icons.FontAwesomeIcons
5863import compose.icons.fontawesomeicons.Brands
5964import compose.icons.fontawesomeicons.brands.Github
6065import compose.icons.fontawesomeicons.brands.Gitlab
66+ import kotlinx.coroutines.*
67+ import kotlinx.coroutines.flow.first
6168import kotlinx.coroutines.flow.mapNotNull
6269import org.koin.compose.koinInject
70+ import com.mikepenz.markdown.model.State as MarkdownRenderState
6371
6472private val ColorValid = Color (0xFF4CAF50 )
6573
@@ -1142,67 +1150,80 @@ fun BundleChangelogDialog(
11421150 onDismissRequest : () -> Unit
11431151) {
11441152 var state: BundleChangelogState by remember { mutableStateOf(BundleChangelogState .Loading ) }
1153+ // 0 = waiting for dialog enter; incremented to trigger fetch, again on retry
1154+ var fetchTrigger by remember { mutableIntStateOf(0 ) }
11451155
1146- LaunchedEffect (Unit ) {
1156+ LaunchedEffect (fetchTrigger) {
1157+ if (fetchTrigger == 0 ) return @LaunchedEffect
11471158 state = BundleChangelogState .Loading
1148- state = try {
1149- val usePrerelease = (src as ? APIPatchBundle )?.usePrerelease == true
1150- || (src as ? JsonPatchBundle )?.usePrerelease == true
1151-
1152- val allEntries = src.fetchChangelogEntries(sinceVersion = null )
1153-
1154- val entries = if (usePrerelease) {
1155- // Prerelease: from the last stable release onwards
1156- val lastStable = allEntries.firstOrNull { ! it.version.contains(" -" ) }
1157- if (lastStable != null )
1158- ChangelogParser .entriesNewerThan(allEntries, lastStable.version) + lastStable
1159- else allEntries
1160- } else {
1161- // Stable: from the installed version onwards
1162- val installed = src.installedVersionSignature
1163- val installedEntry = installed?.let {
1164- ChangelogParser .findVersion(allEntries, it)
1159+ state = withContext(Dispatchers .Default ) {
1160+ try {
1161+ val usePrerelease = (src as ? APIPatchBundle )?.usePrerelease == true
1162+ || (src as ? JsonPatchBundle )?.usePrerelease == true
1163+
1164+ val allEntries = src.fetchChangelogEntries(sinceVersion = null )
1165+
1166+ val entries = if (usePrerelease) {
1167+ // Prerelease: from the last stable release onwards
1168+ val lastStable = allEntries.firstOrNull { ! it.version.contains(" -" ) }
1169+ if (lastStable != null )
1170+ ChangelogParser .entriesNewerThan(allEntries, lastStable.version) + lastStable
1171+ else allEntries.take(30 )
1172+ } else {
1173+ // Stable: from the installed version onwards
1174+ val installed = src.installedVersionSignature
1175+ val installedEntry = installed?.let {
1176+ ChangelogParser .findVersion(allEntries, it)
1177+ }
1178+ val newer = if (installed != null )
1179+ ChangelogParser .entriesNewerThan(allEntries, installed)
1180+ else allEntries
1181+ if (installedEntry != null ) newer + installedEntry else newer
11651182 }
1166- val newer = if (installed != null )
1167- ChangelogParser .entriesNewerThan(allEntries, installed)
1168- else allEntries
1169- if (installedEntry != null ) newer + installedEntry else newer
1170- }
11711183
1172- // APIPatchBundle has endpoint="api" - use SOURCE_REPO_URL directly
1173- val repoUrl = when (src) {
1174- is APIPatchBundle -> SOURCE_REPO_URL
1175- else -> RemotePatchBundle .inferPageUrlFromEndpoint(src.endpoint)
1176- }
1177- val latestPageUrl = entries.firstOrNull()?.version?.let { version ->
1178- val tag = if (version.startsWith(" v" )) version else " v$version "
1179- val url = repoUrl?.let { " $it /releases/tag/$tag " }
1180- url
1181- }
1184+ // APIPatchBundle has endpoint="api" - use SOURCE_REPO_URL directly
1185+ val repoUrl = when (src) {
1186+ is APIPatchBundle -> SOURCE_REPO_URL
1187+ else -> RemotePatchBundle .inferPageUrlFromEndpoint(src.endpoint)
1188+ }
1189+ val latestPageUrl = entries.firstOrNull()?.version?.let { version ->
1190+ repoUrl?.let { releasePageUrl(it, version) }
1191+ }
11821192
1183- if (entries.isNotEmpty()) {
1184- BundleChangelogState .Entries (entries, latestPageUrl = latestPageUrl)
1185- } else {
1186- // Fallback: CHANGELOG.md unavailable - use latest release info from API
1187- val asset = src.fetchLatestReleaseInfo()
1188- BundleChangelogState .Entries (
1189- entries = listOf (
1193+ if (entries.isNotEmpty()) {
1194+ BundleChangelogState .Entries (
1195+ entries = entries,
1196+ parsedMarkdown = preParseEntries(entries),
1197+ latestPageUrl = latestPageUrl
1198+ )
1199+ } else {
1200+ // Fallback: CHANGELOG.md unavailable - use latest release info from API
1201+ val asset = src.fetchLatestReleaseInfo()
1202+ val fallbackEntries = listOf (
11901203 ChangelogEntry (
11911204 version = asset.version,
11921205 date = null ,
11931206 content = asset.description.sanitizePatchChangelogMarkdown()
11941207 )
1195- ),
1196- latestPageUrl = asset.pageUrl
1197- )
1208+ )
1209+ BundleChangelogState .Entries (
1210+ entries = fallbackEntries,
1211+ parsedMarkdown = preParseEntries(fallbackEntries),
1212+ latestPageUrl = asset.pageUrl
1213+ )
1214+ }
1215+ } catch (t: Throwable ) {
1216+ BundleChangelogState .Error (t)
11981217 }
1199- } catch (t: Throwable ) {
1200- BundleChangelogState .Error (t)
12011218 }
12021219 }
12031220
12041221 MorpheDialog (
12051222 onDismissRequest = onDismissRequest,
1223+ // Start fetch only after the dialog enter animation completes so the shimmer
1224+ // is always visible first, even when data is cached and would resolve instantly
1225+ onEntered = { if (fetchTrigger == 0 ) fetchTrigger = 1 },
1226+ scrollable = false ,
12061227 title = when (state) {
12071228 is BundleChangelogState .Entries -> null
12081229 is BundleChangelogState .Error -> stringResource(R .string.changelog)
@@ -1229,7 +1250,7 @@ fun BundleChangelogDialog(
12291250 MorpheDialogButtonColumn {
12301251 MorpheDialogButton (
12311252 text = stringResource(R .string.changelog_retry),
1232- onClick = { state = BundleChangelogState . Loading },
1253+ onClick = { fetchTrigger ++ },
12331254 modifier = Modifier .fillMaxWidth()
12341255 )
12351256 MorpheDialogButton (
@@ -1249,21 +1270,48 @@ fun BundleChangelogDialog(
12491270 }
12501271 }
12511272 ) {
1252- AnimatedContent (
1253- targetState = state,
1254- transitionSpec = MorpheAnimations .fadeCrossfade(),
1255- contentKey = { it::class },
1256- label = " changelog_content"
1257- ) { current ->
1258- when (current) {
1259- BundleChangelogState .Loading -> ChangelogSectionLoading ()
1260- is BundleChangelogState .Error -> BundleChangelogError (error = current.throwable)
1261- is BundleChangelogState .Entries -> ChangelogEntriesList (
1262- entries = current.entries,
1263- headerIcon = Icons .Outlined .History ,
1264- emptyText = stringResource(R .string.changelog_empty),
1265- textColor = LocalDialogTextColor .current
1266- )
1273+ BundleChangelogContent (state)
1274+ }
1275+ }
1276+
1277+ @Composable
1278+ private fun BundleChangelogContent (state : BundleChangelogState ) {
1279+ Crossfade (
1280+ targetState = state,
1281+ animationSpec = tween(MorpheDefaults .ANIMATION_DURATION ),
1282+ modifier = Modifier .fillMaxWidth(),
1283+ label = " changelog_state"
1284+ ) { current ->
1285+ when (current) {
1286+ BundleChangelogState .Loading -> ChangelogSectionLoading ()
1287+ is BundleChangelogState .Error -> BundleChangelogError (error = current.throwable)
1288+ is BundleChangelogState .Entries -> {
1289+ if (current.entries.isEmpty()) {
1290+ Text (
1291+ text = stringResource(R .string.changelog_empty),
1292+ style = MaterialTheme .typography.bodyMedium,
1293+ color = MaterialTheme .colorScheme.onSurfaceVariant,
1294+ modifier = Modifier .fillMaxWidth()
1295+ )
1296+ } else {
1297+ val textColor = LocalDialogTextColor .current
1298+ LazyColumn (modifier = Modifier .fillMaxWidth()) {
1299+ itemsIndexed(current.entries) { index, entry ->
1300+ if (index > 0 ) {
1301+ HorizontalDivider (
1302+ modifier = Modifier .padding(vertical = 20 .dp),
1303+ color = MaterialTheme .colorScheme.outlineVariant.copy(alpha = 0.4f )
1304+ )
1305+ }
1306+ ChangelogEntrySection (
1307+ entry = entry,
1308+ headerIcon = Icons .Outlined .History ,
1309+ textColor = textColor,
1310+ precomputedMarkdown = current.parsedMarkdown.getOrNull(index)
1311+ )
1312+ }
1313+ }
1314+ }
12671315 }
12681316 }
12691317 }
@@ -1318,11 +1366,25 @@ private sealed interface BundleChangelogState {
13181366 /* * [entries] are already filtered to "missed" versions, newest-first. */
13191367 data class Entries (
13201368 val entries : List <ChangelogEntry >,
1369+ val parsedMarkdown : List <MarkdownRenderState ?>,
13211370 val latestPageUrl : String?
13221371 ) : BundleChangelogState
13231372 data class Error (val throwable : Throwable ) : BundleChangelogState
13241373}
13251374
1375+ private suspend fun preParseEntries (entries : List <ChangelogEntry >): List <MarkdownRenderState ?> =
1376+ coroutineScope {
1377+ entries.map { entry ->
1378+ async {
1379+ if (entry.content.isBlank()) null
1380+ else runCatching {
1381+ parseMarkdownFlow(entry.content.trimIndent())
1382+ .first { it !is MarkdownRenderState .Loading }
1383+ }.getOrNull()
1384+ }
1385+ }.awaitAll()
1386+ }
1387+
13261388private val doubleBracketLinkRegex = Regex (""" \[\[([^]]+)]\(([^)]+)\)]""" )
13271389
13281390private fun String.sanitizePatchChangelogMarkdown (): String =
0 commit comments