Skip to content

Commit 1881991

Browse files
authored
feat: Improve patch visibility in bundle and app patch dialogs (#457)
1 parent 1b97554 commit 1881991

9 files changed

Lines changed: 790 additions & 363 deletions

File tree

app/src/main/java/app/morphe/manager/ui/screen/HomeScreen.kt

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ fun HomeScreen(
5151
val view = LocalView.current
5252

5353
// Dialog states
54-
val showInstalledAppDialog = remember { mutableStateOf<String?>(null) }
5554
val showUpdateDetailsDialog = remember { mutableStateOf(false) }
5655

5756
// Patches dialog state (swipe-right on app card)
@@ -163,21 +162,6 @@ fun HomeScreen(
163162
)
164163
}
165164

166-
// Installed App Info Dialog
167-
showInstalledAppDialog.value?.let { packageName ->
168-
key(packageName) {
169-
InstalledAppInfoDialog(
170-
packageName = packageName,
171-
onDismiss = { showInstalledAppDialog.value = null },
172-
onTriggerPatchFlow = { originalPackageName ->
173-
showInstalledAppDialog.value = null
174-
homeViewModel.showPatchDialog(originalPackageName)
175-
},
176-
homeViewModel = homeViewModel
177-
)
178-
}
179-
}
180-
181165
// All dialogs
182166
HomeDialogs(
183167
homeViewModel = homeViewModel,
@@ -229,7 +213,9 @@ fun HomeScreen(
229213
android11BugActive = homeViewModel.android11BugActive,
230214
installedApp = item.installedApp
231215
)
232-
item.installedApp?.let { showInstalledAppDialog.value = it.currentPackageName }
216+
item.installedApp?.let {
217+
homeViewModel.openInstalledAppInfo(it.currentPackageName)
218+
}
233219
},
234220
onHideApp = { packageName -> homeViewModel.hideApp(packageName) },
235221
onHideMultiple = { packageNames -> packageNames.forEach { homeViewModel.hideApp(it) } },

app/src/main/java/app/morphe/manager/ui/screen/home/HomeDialogs.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,21 @@ fun HomeDialogs(
287287
)
288288
}
289289

290+
// Installed App Info Dialog
291+
homeViewModel.showInstalledAppInfoDialog?.let { packageName ->
292+
key(packageName, homeViewModel.installedAppDialogToken) {
293+
InstalledAppInfoDialog(
294+
packageName = packageName,
295+
onDismiss = homeViewModel::dismissInstalledAppInfo,
296+
onTriggerPatchFlow = { originalPackageName ->
297+
homeViewModel.dismissInstalledAppInfo()
298+
homeViewModel.showPatchDialog(originalPackageName)
299+
},
300+
homeViewModel = homeViewModel
301+
)
302+
}
303+
}
304+
290305
// Expert Mode Dialog
291306
if (homeViewModel.showExpertModeDialog) {
292307
ExpertModeDialog(

app/src/main/java/app/morphe/manager/ui/screen/home/InstalledAppInfoDialog.kt

Lines changed: 609 additions & 310 deletions
Large diffs are not rendered by default.

app/src/main/java/app/morphe/manager/ui/screen/home/SectionsLayout.kt

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,9 +1597,9 @@ fun AppPatchesDialog(
15971597
onDismiss: () -> Unit
15981598
) {
15991599
// Flatten to a list of (bundleUid, patch).
1600-
// Bundle ordering: bundles with at least one specific patch come first (alphabetically),
1601-
// then bundles with only universal patches.
1602-
// Within each bundle: specific patches first, universal patches last.
1600+
// Bundle ordering: bundles with at least one specific patch come first (by name),
1601+
// then bundles with only universal patches (by name).
1602+
// Within each bundle: specific patches first (alphabetically), universal patches last (alphabetically).
16031603
val allPatches = remember(patchesByBundle, bundleNames) {
16041604
patchesByBundle.entries
16051605
.sortedWith(
@@ -1616,6 +1616,18 @@ fun AppPatchesDialog(
16161616
}
16171617

16181618
val isMultiBundle = patchesByBundle.size > 1
1619+
1620+
// Per-bundle accent color for multi-bundle mode only.
1621+
// Generated deterministically from uid via multiplicative hash → HSL,
1622+
// so the same uid always produces the same color.
1623+
// Returns null for single-bundle (no coloring needed).
1624+
val bundleAccentColors: Map<Int, Color> = remember(patchesByBundle, isMultiBundle) {
1625+
if (!isMultiBundle) return@remember emptyMap()
1626+
patchesByBundle.keys.associateWith { uid ->
1627+
val hue = ((uid.hashCode() * 2654435761L) and 0xFFFFFFFFL).toFloat() % 360f
1628+
Color.hsl(hue = hue, saturation = 0.55f, lightness = 0.60f)
1629+
}
1630+
}
16191631
var searchQuery by remember { mutableStateOf("") }
16201632
var selectedBundle by remember { mutableStateOf<Int?>(null) }
16211633
val showFilterSheet = remember { mutableStateOf(false) }
@@ -1634,6 +1646,26 @@ fun AppPatchesDialog(
16341646
val isFiltering = searchQuery.isNotBlank() || selectedBundle != null
16351647
val totalCount = allPatches.size
16361648

1649+
// Pre-compute per-bundle markers once so items{} can do O(1) lookups instead of O(n) scans
1650+
val firstPatchPerBundle: Map<Int, PatchInfo> = remember(filteredPatches) {
1651+
buildMap {
1652+
filteredPatches.forEach { (uid, patch) -> putIfAbsent(uid, patch) }
1653+
}
1654+
}
1655+
val firstUniversalPerBundle: Map<Int, PatchInfo> = remember(filteredPatches) {
1656+
buildMap {
1657+
filteredPatches.forEach { (uid, patch) ->
1658+
if (patch.compatiblePackages == null) putIfAbsent(uid, patch)
1659+
}
1660+
}
1661+
}
1662+
val bundlesWithSpecificPatches: Set<Int> = remember(filteredPatches) {
1663+
filteredPatches
1664+
.filter { (_, patch) -> patch.compatiblePackages != null }
1665+
.map { it.first }
1666+
.toSet()
1667+
}
1668+
16371669
MorpheDialog(
16381670
onDismissRequest = onDismiss,
16391671
dismissOnClickOutside = true,
@@ -1850,34 +1882,35 @@ fun AppPatchesDialog(
18501882
key = { (uid, patch) ->
18511883
"$uid:${patch.name}:${patch.compatiblePackages?.joinToString { it.packageName.orEmpty() }.orEmpty()}"
18521884
}
1853-
) { (uid, patch) ->
1885+
) { entry ->
1886+
val uid: Int = entry.first
1887+
val patch: PatchInfo = entry.second
18541888
val isUniversal = patch.compatiblePackages == null
1855-
Column {
1889+
Column(
1890+
modifier = Modifier.animateItem(
1891+
fadeInSpec = tween(220),
1892+
fadeOutSpec = tween(180),
1893+
placementSpec = spring(stiffness = 400f, dampingRatio = 0.8f)
1894+
)
1895+
) {
18561896
// Bundle section label - only for multi-bundle, at first patch of each bundle
18571897
if (isMultiBundle) {
1858-
val isFirstOfBundle = remember(filteredPatches, uid, patch) {
1859-
filteredPatches.firstOrNull { it.first == uid }?.second == patch
1860-
}
1898+
val isFirstOfBundle = firstPatchPerBundle[uid] == patch
18611899
if (isFirstOfBundle) {
1862-
Text(
1900+
InfoBadge(
18631901
text = bundleNames[uid] ?: uid.toString(),
1864-
style = MaterialTheme.typography.titleSmall,
1865-
fontWeight = FontWeight.Bold,
1866-
color = MaterialTheme.colorScheme.primary,
1902+
style = InfoBadgeStyle.Primary,
1903+
icon = Icons.Outlined.Layers,
1904+
isExpanded = true,
18671905
modifier = Modifier.padding(bottom = 6.dp, top = 8.dp)
18681906
)
18691907
}
18701908
}
18711909

18721910
// Universal patches divider - shown before the first universal patch of each bundle
1873-
val isFirstUniversalOfBundle = remember(filteredPatches, uid, patch) {
1874-
if (!isUniversal) return@remember false
1875-
filteredPatches.firstOrNull { (u, p) -> u == uid && p.compatiblePackages == null }?.second == patch
1876-
}
1911+
val isFirstUniversalOfBundle = isUniversal && firstUniversalPerBundle[uid] == patch
18771912
if (isFirstUniversalOfBundle) {
1878-
val hasSpecificAbove = remember(filteredPatches, uid) {
1879-
filteredPatches.any { (u, p) -> u == uid && p.compatiblePackages != null }
1880-
}
1913+
val hasSpecificAbove = uid in bundlesWithSpecificPatches
18811914
Row(
18821915
modifier = Modifier
18831916
.fillMaxWidth()
@@ -1907,11 +1940,7 @@ fun AppPatchesDialog(
19071940
PatchItemCard(
19081941
patch = patch,
19091942
saveStateKey = "app_patches_${item.packageName}_$uid",
1910-
modifier = Modifier.animateItem(
1911-
fadeInSpec = tween(220),
1912-
fadeOutSpec = tween(180),
1913-
placementSpec = spring(stiffness = 400f, dampingRatio = 0.8f)
1914-
)
1943+
accentColor = bundleAccentColors[uid],
19151944
)
19161945
}
19171946
}

app/src/main/java/app/morphe/manager/ui/screen/home/SourceManagementDialogs.kt

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
package app.morphe.manager.ui.screen.home
77

88
import android.annotation.SuppressLint
9+
import android.graphics.Color.argb
10+
import android.graphics.Color.colorToHSV
911
import androidx.compose.animation.*
1012
import androidx.compose.animation.core.animateFloatAsState
1113
import androidx.compose.animation.core.spring
@@ -395,6 +397,16 @@ fun BundlePatchesDialog(
395397
.sortedBy { it.name }
396398
}
397399

400+
// Per-patch accent color: first non-null appIconColor across all compatible packages,
401+
// converted from 0xRRGGBB to a full-opacity Compose Color. Null falls back to surfaceVariant.
402+
val patchAccentColors: Map<String, Color> = remember(patches) {
403+
patches.associate { patch ->
404+
val rgb = patch.compatiblePackages
405+
?.firstNotNullOfOrNull { it.appIconColor }
406+
patch.name to if (rgb != null) Color(rgb or (0xFF shl 24)) else Color.Unspecified
407+
}
408+
}
409+
398410
val isFiltering = searchQuery.isNotBlank() || selectedPackages.isNotEmpty()
399411

400412
MorpheDialog(
@@ -628,12 +640,15 @@ fun BundlePatchesDialog(
628640
}
629641
) { patch ->
630642
val context = LocalContext.current
643+
val accentColor = patchAccentColors[patch.name]
644+
?.takeIf { it != Color.Unspecified }
631645
PatchItemCard(
632646
patch = patch,
633647
saveStateKey = "bundle_${src.uid}",
634648
onExpertBadgeClick = if (!patch.include) {
635649
{ context.toast(context.getString(R.string.sources_patch_expert_badge_tooltip)) }
636650
} else null,
651+
accentColor = accentColor,
637652
modifier = Modifier.animateItem(
638653
fadeInSpec = tween(220),
639654
fadeOutSpec = tween(180),
@@ -715,7 +730,8 @@ fun PatchItemCard(
715730
modifier: Modifier = Modifier,
716731
patch: PatchInfo,
717732
saveStateKey: String,
718-
onExpertBadgeClick: (() -> Unit)? = null
733+
onExpertBadgeClick: (() -> Unit)? = null,
734+
accentColor: Color? = null
719735
) {
720736
val textColor = LocalDialogTextColor.current
721737
val secondaryColor = LocalDialogSecondaryTextColor.current
@@ -733,6 +749,23 @@ fun PatchItemCard(
733749
label = "expand_rotation"
734750
)
735751

752+
// Cache the card background color: colorToHSV is a native call that allocates a FloatArray
753+
val cardColor = remember(accentColor) {
754+
if (accentColor != null) {
755+
val hsv = FloatArray(3)
756+
colorToHSV(
757+
argb(
758+
255,
759+
(accentColor.red * 255).toInt(),
760+
(accentColor.green * 255).toInt(),
761+
(accentColor.blue * 255).toInt()
762+
),
763+
hsv
764+
)
765+
Color.hsl(hue = hsv[0], saturation = 0.35f, lightness = 0.55f, alpha = 0.2f)
766+
} else null
767+
}
768+
736769
Surface(
737770
modifier = modifier
738771
.fillMaxWidth()
@@ -743,7 +776,7 @@ fun PatchItemCard(
743776
} else Modifier
744777
),
745778
shape = RoundedCornerShape(14.dp),
746-
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
779+
color = cardColor ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
747780
) {
748781
Column(
749782
modifier = Modifier.padding(16.dp),

app/src/main/java/app/morphe/manager/ui/screen/shared/AppLabel.kt

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ import kotlinx.coroutines.withContext
2323
import org.koin.compose.koinInject
2424

2525
/**
26-
* Universal app label component
26+
* Universal app label component.
2727
*
2828
* Automatically resolves label from available sources:
29-
* installed app → original APK → patched APK → constants → package name fallback
29+
* installed app → original APK → patched APK → constants → package name fallback.
3030
*/
3131
@Composable
3232
fun AppLabel(
@@ -77,7 +77,7 @@ fun AppLabel(
7777
}
7878

7979
/**
80-
* Simple label display when PackageInfo is already available
80+
* Simple label display when PackageInfo is already available.
8181
*/
8282
@Composable
8383
private fun SimpleAppLabel(
@@ -89,10 +89,11 @@ private fun SimpleAppLabel(
8989
overflow: TextOverflow = TextOverflow.Clip
9090
) {
9191
val context = LocalContext.current
92-
var label: String? by rememberSaveable { mutableStateOf(null) }
9392

94-
LaunchedEffect(packageInfo) {
95-
label = withContext(Dispatchers.IO) {
93+
// Attempt a cheap synchronous resolution first so we never show a shimmer
94+
// when the label is already available in memory
95+
val initialLabel = remember(packageInfo.packageName) {
96+
runCatching {
9697
packageInfo.applicationInfo?.loadLabel(context.packageManager)
9798
?.toString()
9899
?.let { raw ->
@@ -101,12 +102,32 @@ private fun SimpleAppLabel(
101102
}
102103
?: packageInfo.applicationInfo?.nonLocalizedLabel?.toString()
103104
?.takeIf { it.isNotBlank() }
104-
?: defaultText
105+
}.getOrNull()
106+
}
107+
108+
var label: String? by rememberSaveable(packageInfo.packageName) {
109+
mutableStateOf(initialLabel)
110+
}
111+
112+
// Only dispatch to IO when the synchronous attempt didn't produce a result
113+
if (label == null) {
114+
LaunchedEffect(packageInfo.packageName) {
115+
label = withContext(Dispatchers.IO) {
116+
packageInfo.applicationInfo?.loadLabel(context.packageManager)
117+
?.toString()
118+
?.let { raw ->
119+
val cleaned = cleanWeirdLabel(raw, packageInfo.packageName)
120+
cleaned.takeIf { it.isNotBlank() && cleaned != packageInfo.packageName }
121+
}
122+
?: packageInfo.applicationInfo?.nonLocalizedLabel?.toString()
123+
?.takeIf { it.isNotBlank() }
124+
?: defaultText
125+
}
105126
}
106127
}
107128

108129
Text(
109-
label ?: stringResource(R.string.loading),
130+
label ?: defaultText ?: stringResource(R.string.loading),
110131
modifier = Modifier
111132
.placeholder(
112133
visible = label == null,
@@ -121,7 +142,7 @@ private fun SimpleAppLabel(
121142
}
122143

123144
/**
124-
* Resolved label from any available source when only package name is known
145+
* Resolved label from any available source when only package name is known.
125146
*/
126147
@Composable
127148
private fun ResolvedAppLabel(
@@ -171,7 +192,7 @@ private fun ResolvedAppLabel(
171192
}
172193

173194
/**
174-
* Clean weird labels that contain package name or other artifacts
195+
* Clean weird labels that contain package name or other artifacts.
175196
*/
176197
private fun cleanWeirdLabel(raw: String, packageName: String?): String {
177198
val trimmed = raw.trim()

0 commit comments

Comments
 (0)