diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cdda56e..921a07c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + HomeScreen() } } } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - AndroidPerfLabTheme { - Greeting("Android") - } } \ No newline at end of file diff --git a/app/src/main/java/com/aquib/androidperflab/sdk/FakeAnalyticsSdk.kt b/app/src/main/java/com/aquib/androidperflab/sdk/FakeAnalyticsSdk.kt new file mode 100644 index 0000000..a3f45e8 --- /dev/null +++ b/app/src/main/java/com/aquib/androidperflab/sdk/FakeAnalyticsSdk.kt @@ -0,0 +1,42 @@ +package com.aquib.androidperflab.sdk + +import android.content.Context +import android.os.Build +import android.util.Log + +object FakeAnalyticsSdk { + + // Simulates: opening a local SQLite event queue, reading persisted user identity from + // SharedPreferences, building a device fingerprint, and doing a blocking fetch of + // the remote analytics endpoint — all on the main thread. + fun init(context: Context) { + // Simulate: hydrating the in-memory event schema (heavy map allocation) + val eventSchema = HashMap(512) + repeat(500) { i -> eventSchema["event_type_$i"] = "schema_v2_$i" } + + // Simulate: reading / writing user identity from SharedPreferences + val prefs = context.getSharedPreferences("fake_analytics", Context.MODE_PRIVATE) + val userId = prefs.getString("user_id", null) ?: run { + val generated = System.nanoTime().toString(16) + // commit() instead of apply() — blocks until disk write completes + prefs.edit().putString("user_id", generated).commit() + generated + } + val sessionIndex = prefs.getInt("session_index", 0) + 1 + prefs.edit().putInt("session_index", sessionIndex).commit() + + // Simulate: device fingerprinting (string concat over multiple Build fields) + @Suppress("DEPRECATION") + val fingerprint = buildString { + append(Build.MANUFACTURER); append('_') + append(Build.MODEL); append('_') + append(Build.DEVICE); append('_') + append(Build.FINGERPRINT.takeLast(12)) + } + + // Simulate: blocking endpoint resolution + SDK handshake over network + Thread.sleep(180L) + + Log.d("FakeAnalyticsSdk", "init complete — user=$userId session=$sessionIndex fp=$fingerprint schema=${eventSchema.size} entries") + } +} diff --git a/app/src/main/java/com/aquib/androidperflab/sdk/FakeCrashReportingSdk.kt b/app/src/main/java/com/aquib/androidperflab/sdk/FakeCrashReportingSdk.kt new file mode 100644 index 0000000..1db7699 --- /dev/null +++ b/app/src/main/java/com/aquib/androidperflab/sdk/FakeCrashReportingSdk.kt @@ -0,0 +1,45 @@ +package com.aquib.androidperflab.sdk + +import android.content.Context +import android.util.Log +import java.io.File + +object FakeCrashReportingSdk { + + // Simulates: scanning the cache directory for pending crash dumps left by a previous + // session, installing an UncaughtExceptionHandler, and uploading any found dumps + // synchronously before the app is considered "ready". + fun init(context: Context) { + // Simulate: scanning cache dir for pending crash dump files + val cacheDir = context.cacheDir + val crashDumps = cacheDir.listFiles { f -> f.name.startsWith("crash_") } + ?.toList() + ?: emptyList() + + // Simulate: parsing each crash dump (string + I/O work per file) + val parsedReports = crashDumps.map { file -> + buildString { + append("report["); append(file.name); append("]: ") + append(file.length()); append(" bytes, modified=") + append(file.lastModified()) + } + } + + // Simulate: writing a session sentinel file so the next launch can detect + // whether this session ended cleanly + val sentinel = File(cacheDir, "crash_sentinel_${System.currentTimeMillis()}.tmp") + sentinel.createNewFile() + + // Simulate: registering the uncaught exception handler (involves thread locking) + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + Log.e("FakeCrashReportingSdk", "Uncaught exception on ${thread.name}", throwable) + previousHandler?.uncaughtException(thread, throwable) + } + + // Simulate: blocking upload of any pending crash reports to the backend + Thread.sleep(120L) + + Log.d("FakeCrashReportingSdk", "init complete — found ${crashDumps.size} pending dumps, parsed ${parsedReports.size} reports") + } +} diff --git a/app/src/main/java/com/aquib/androidperflab/sdk/FakeFeatureFlagsSdk.kt b/app/src/main/java/com/aquib/androidperflab/sdk/FakeFeatureFlagsSdk.kt new file mode 100644 index 0000000..ae3bbb4 --- /dev/null +++ b/app/src/main/java/com/aquib/androidperflab/sdk/FakeFeatureFlagsSdk.kt @@ -0,0 +1,40 @@ +package com.aquib.androidperflab.sdk + +import android.content.Context +import android.util.Log + +object FakeFeatureFlagsSdk { + + private val flags = HashMap() + + // Simulates: parsing a large bundled JSON payload of flag definitions, evaluating + // per-user targeting rules against locally stored user attributes, and persisting + // the resolved flag state — all synchronously on the main thread. + fun init(context: Context) { + // Simulate: deserialising bundled flag defaults (CPU-bound JSON parsing) + val rawPayload = buildString { + repeat(200) { i -> + append("""{"flag":"feature_$i","enabled":${i % 3 != 0},"rollout":${(i * 7) % 100}},""") + } + } + + // Simulate: evaluating targeting rules for each flag + val prefs = context.getSharedPreferences("fake_feature_flags", Context.MODE_PRIVATE) + val userSegment = prefs.getInt("user_segment", (System.nanoTime() % 100).toInt()) + prefs.edit().putInt("user_segment", userSegment).commit() + + repeat(200) { i -> + val rollout = (i * 7) % 100 + flags["feature_$i"] = (i % 3 != 0) && (userSegment < rollout) + } + + val enabledCount = flags.values.count { it } + + // Simulate: blocking network sync to fetch any server-side flag overrides + Thread.sleep(150L) + + Log.d("FakeFeatureFlagsSdk", "init complete — ${flags.size} flags resolved, $enabledCount enabled for segment=$userSegment payload=${rawPayload.length} chars") + } + + fun isEnabled(flag: String): Boolean = flags[flag] ?: false +} diff --git a/app/src/main/java/com/aquib/androidperflab/sdk/FakePerformanceMonitorSdk.kt b/app/src/main/java/com/aquib/androidperflab/sdk/FakePerformanceMonitorSdk.kt new file mode 100644 index 0000000..93963ec --- /dev/null +++ b/app/src/main/java/com/aquib/androidperflab/sdk/FakePerformanceMonitorSdk.kt @@ -0,0 +1,53 @@ +package com.aquib.androidperflab.sdk + +import android.app.ActivityManager +import android.content.Context +import android.os.Debug +import android.os.Process +import android.util.Log + +object FakePerformanceMonitorSdk { + + // Simulates: snapshotting baseline memory stats, reading /proc/self/status for CPU + // accounting, and installing a frame-timing callback — all synchronously on the main + // thread before the first frame is drawn. + fun init(context: Context) { + // Simulate: collecting baseline memory snapshot + val runtime = Runtime.getRuntime() + val totalMemory = runtime.totalMemory() + val freeMemory = runtime.freeMemory() + val nativeHeap = Debug.getNativeHeapSize() + + // Simulate: reading ActivityManager memory info + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memInfo) + + // Simulate: reading per-process CPU stats from procfs (string parsing) + val pid = Process.myPid() + val procStatLines = buildString { + repeat(80) { i -> append("cpu_stat_field_${i}=${pid + i}\n") } + }.lines() + + // Simulate: building baseline metric registry (100 named counters) + val metrics = HashMap(128) + repeat(100) { i -> + metrics["metric_$i"] = System.nanoTime() + i + } + + // Simulate: GC pause measurement — forces a GC and measures its cost + val gcBefore = Debug.getRuntimeStat("art.gc.gc-count") + System.gc() + val gcAfter = Debug.getRuntimeStat("art.gc.gc-count") + + // Simulate: installing frame-timing infrastructure (method tracing warmup) + Thread.sleep(100L) + + Log.d( + "FakePerfMonitorSdk", + "init complete — heap=${totalMemory / 1024}KB free=${freeMemory / 1024}KB " + + "nativeHeap=${nativeHeap / 1024}KB availMem=${memInfo.availMem / 1024}KB " + + "procStatLines=${procStatLines.size} metrics=${metrics.size} gc=$gcBefore→$gcAfter", + ) + } +} diff --git a/app/src/main/java/com/aquib/androidperflab/sdk/FakeRemoteConfigSdk.kt b/app/src/main/java/com/aquib/androidperflab/sdk/FakeRemoteConfigSdk.kt new file mode 100644 index 0000000..d7f5302 --- /dev/null +++ b/app/src/main/java/com/aquib/androidperflab/sdk/FakeRemoteConfigSdk.kt @@ -0,0 +1,49 @@ +package com.aquib.androidperflab.sdk + +import android.content.Context +import android.util.Log + +object FakeRemoteConfigSdk { + + private val config = HashMap() + + // Simulates: reading the last-fetched remote config blob from SharedPreferences, + // validating an HMAC checksum, deserialising key-value pairs, and resolving + // parameter overrides — synchronously blocking the main thread. + fun init(context: Context) { + val prefs = context.getSharedPreferences("fake_remote_config", Context.MODE_PRIVATE) + + // Simulate: reading + validating a previously cached config blob + val cachedBlob = prefs.getString("config_blob", null) + val storedChecksum = prefs.getString("config_checksum", "") + + if (cachedBlob != null) { + // Simulate: checksum validation (string hashing) + val computedChecksum = cachedBlob.fold(0L) { acc, c -> acc * 31 + c.code }.toString(16) + val valid = computedChecksum == storedChecksum + Log.d("FakeRemoteConfigSdk", "cache checksum valid=$valid") + } + + // Simulate: building a new in-memory config with 150 keys + val newBlob = buildString { + repeat(150) { i -> + val key = "config_key_$i" + val value = "value_${i}_${System.nanoTime() % 1000}" + config[key] = value + append("$key=$value;") + } + } + val checksum = newBlob.fold(0L) { acc, c -> acc * 31 + c.code }.toString(16) + prefs.edit() + .putString("config_blob", newBlob) + .putString("config_checksum", checksum) + .commit() + + // Simulate: blocking fetch of fresh config values from the remote endpoint + Thread.sleep(200L) + + Log.d("FakeRemoteConfigSdk", "init complete — ${config.size} keys loaded, checksum=$checksum") + } + + fun getString(key: String, default: String = ""): String = config[key] ?: default +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b85270..c1cdfc9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,9 @@ benchmarkMacro = "1.5.0-alpha05" profileinstaller = "1.4.1" uiautomator = "2.3.0" +# Coil +coil = "3.0.4" + # Test junit = "4.13.2" junitVersion = "1.2.1" @@ -50,6 +53,10 @@ androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = # Baseline Profiles — consumer side (installed into the APK) androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } +# Coil +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } + # Test junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 586f0ae..a30140e 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose.ui) implementation(libs.androidx.core.ktx) + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/ui/src/main/java/com/aquib/androidperflab/ui/AnimatedListScreen.kt b/ui/src/main/java/com/aquib/androidperflab/ui/AnimatedListScreen.kt new file mode 100644 index 0000000..62d3e94 --- /dev/null +++ b/ui/src/main/java/com/aquib/androidperflab/ui/AnimatedListScreen.kt @@ -0,0 +1,163 @@ +package com.aquib.androidperflab.ui + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +private data class AnimatedItem( + val id: Int, + val title: String, + val subtitle: String, + val body: String, +) + +private fun generateAnimatedItems(count: Int = 80): List = List(count) { i -> + AnimatedItem( + id = i, + title = "Animation Demo Item #$i", + subtitle = "Tap to expand · item ${i + 1} of $count", + body = "This card has animateContentSize applied, an alpha that reads State " + + "in composition scope (not inside a graphicsLayer lambda), and a Color " + + "constructed inline on every recomposition. All intentionally unoptimized.", + ) +} + +@Composable +fun AnimatedListScreen( + onBack: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val items = remember { generateAnimatedItems() } + Column(modifier = modifier.fillMaxSize()) { + TextButton(onClick = onBack, modifier = Modifier.padding(start = 4.dp, top = 4.dp)) { + Text("← Back") + } + LazyColumn(modifier = Modifier.fillMaxSize()) { + // BAD: no key lambda — Compose cannot track item identity across recompositions, + // so any structural change causes it to diff by position rather than by id. + items(items) { item -> + AnimatedListCard(item = item) + HorizontalDivider() + } + } + } +} + +@Composable +private fun AnimatedListCard(item: AnimatedItem) { + var expanded by remember { mutableStateOf(false) } + + // BAD: animatedAlpha is a State whose value changes every ~16 ms while the + // animation runs. Reading it here (via the `by` delegate) subscribes this composable + // to that state, so the ENTIRE composable — and every child inside it — recomposes + // on every single animation frame. + // + // Correct fix: don't read the state here at all. Instead, pass it into a graphicsLayer + // lambda where only the draw phase is invalidated: + // Modifier.graphicsLayer { alpha = animatedAlpha } + val infiniteTransition = rememberInfiniteTransition(label = "pulse_${item.id}") + val animatedAlpha by infiniteTransition.animateFloat( + initialValue = 0.50f, + targetValue = 1.00f, + animationSpec = infiniteRepeatable( + // Stagger durations slightly so items don't all flash in sync. + animation = tween(durationMillis = 600 + (item.id % 10) * 80, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "alpha_${item.id}", + ) + + // BAD: new Color object allocated on every recomposition — should be a top-level + // constant or wrapped in remember { Color(...) }. + val accentColor = Color( + red = (item.id * 37 % 200 + 55) / 255f, + green = (item.id * 71 % 180 + 50) / 255f, + blue = (item.id * 13 % 220 + 35) / 255f, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + // BAD 1: animateContentSize on every item in the list. + // When any card expands, the LayoutModifier runs its size interpolation on + // every animation frame for every card that has this modifier — not just the + // one the user tapped. + .animateContentSize() + // BAD 2: Modifier.alpha() evaluates its argument during composition, so the + // state read above makes this whole subtree recompose every frame. + // Modifier.graphicsLayer { alpha = animatedAlpha } would confine the read to + // the draw phase and skip recomposition entirely. + .alpha(animatedAlpha) + .background(accentColor.copy(alpha = 0.07f)) + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall, + color = accentColor, + ) + Text( + text = item.subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + Text( + text = if (expanded) "▲" else "▼", + style = MaterialTheme.typography.labelMedium, + color = accentColor, + ) + } + + if (expanded) { + Spacer(modifier = Modifier.height(8.dp)) + Text(text = item.body, style = MaterialTheme.typography.bodySmall) + Spacer(modifier = Modifier.height(4.dp)) + // Extra lines increase the layout cost when animateContentSize runs. + repeat(4) { line -> + Text( + text = "Detail line ${line + 1} — item #${item.id}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } +} diff --git a/ui/src/main/java/com/aquib/androidperflab/ui/DetailScreen.kt b/ui/src/main/java/com/aquib/androidperflab/ui/DetailScreen.kt new file mode 100644 index 0000000..28e8275 --- /dev/null +++ b/ui/src/main/java/com/aquib/androidperflab/ui/DetailScreen.kt @@ -0,0 +1,317 @@ +package com.aquib.androidperflab.ui + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import kotlinx.coroutines.delay +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@SuppressLint("UnrememberedMutableState") +@Composable +fun DetailScreen( + item: FeedItem, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + // The only remember{} in this composable — drives continuous recompositions so every + // bad practice below has a visible cost during profiling. + var tick by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { while (true) { delay(500L); tick++ } } + + // BAD: mutableStateOf without remember — both values reset to 0 on every recomposition, + // so user interactions never persist across a recompose cycle. + var likeCount by mutableStateOf(0) + var bookmarkCount by mutableStateOf(0) + + // BAD: no derivedStateOf — isPopular recalculates on every recomposition of DetailScreen + // (e.g. each tick), not only when likeCount actually changes. + val isPopular = likeCount > 50 + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + DetailBackButton(onBack = onBack) // 1 BAD: inline lambda + DetailHeroImage(url = item.imageUrl, tick = tick) // 2 recomposes every 500 ms + DetailTimestamp(millis = item.timestampMillis) // 3 BAD: inline SimpleDateFormat + DetailTitle(title = item.title, isPopular = isPopular) // 4 re-evaluates each tick + DetailTagsRow(title = item.title) // 5 BAD: split() inline + DetailReadingTime(description = item.description) // 6 BAD: word count inline + DetailLiveCounter(tick = tick) // 7 ticks every 500 ms + DetailInteractionBar( // 8 BAD: inline lambdas + likeCount = likeCount, + bookmarkCount = bookmarkCount, + onLike = { likeCount++ }, + onBookmark = { bookmarkCount++ }, + ) + DetailDescription(description = item.description) // 9 + DetailStatsGrid(id = item.id) // 10 BAD: arithmetic inline + DetailRelatedSection(sourceId = item.id) // 11 BAD: no key, inline List + DetailAuthorCard(author = item.author, id = item.id) // 12 BAD: inline Color + ops + } +} + +// ── Child composables ──────────────────────────────────────────────────────── + +@Composable +private fun DetailBackButton(onBack: () -> Unit) { + // BAD: onBack is an inline lambda at the call site — a new instance each recomposition, + // so Compose cannot skip recomposing this child. + TextButton(onClick = onBack) { + Text("← Back to feed") + } +} + +@Composable +private fun DetailHeroImage(url: String, tick: Int) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .clip(RoundedCornerShape(12.dp)), + ) + Text( + text = "Live updates: $tick", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) +} + +@Composable +private fun DetailTimestamp(millis: Long) { + // BAD: new SimpleDateFormat and Date allocated on every recomposition. + val formatted = SimpleDateFormat("EEEE, MMMM dd yyyy 'at' HH:mm:ss", Locale.getDefault()) + .format(Date(millis)) + Text( + text = formatted, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline, + ) +} + +@Composable +private fun DetailTitle(title: String, isPopular: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + if (isPopular) { + Text( + text = "🔥 Popular", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } +} + +@Composable +private fun DetailTagsRow(title: String) { + // BAD: split + filter + map run on every recomposition — should be remember { }. + val tags = title.split(" ").filter { it.length > 3 } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + // BAD: no key {} — Compose cannot track tag identity across recompositions. + tags.forEach { tag -> + Text( + text = "#${tag.lowercase()}", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .background( + MaterialTheme.colorScheme.secondaryContainer, + RoundedCornerShape(4.dp), + ) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } +} + +@Composable +private fun DetailReadingTime(description: String) { + // BAD: split + size called on every recomposition — should be remember { }. + val wordCount = description.split(" ").size + val minutes = (wordCount / 200).coerceAtLeast(1) + Text( + text = "$wordCount words · $minutes min read", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) +} + +@Composable +private fun DetailLiveCounter(tick: Int) { + Text( + text = "Recomposed $tick times", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun DetailInteractionBar( + likeCount: Int, + bookmarkCount: Int, + onLike: () -> Unit, + onBookmark: () -> Unit, +) { + // BAD: no derivedStateOf — these strings are rebuilt every recomposition even when + // likeCount / bookmarkCount have not changed (e.g. on each tick). + val likeLabel = "♥ Like ($likeCount)" + val bookmarkLabel = "🔖 Save ($bookmarkCount)" + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // BAD: onLike / onBookmark are inline lambdas — new instances each recomposition, + // preventing Compose from skipping Button recomposition. + Button(onClick = onLike) { Text(likeLabel) } + OutlinedButton(onClick = onBookmark) { Text(bookmarkLabel) } + } +} + +@Composable +private fun DetailDescription(description: String) { + Text(text = description, style = MaterialTheme.typography.bodyMedium) +} + +@Composable +private fun DetailStatsGrid(id: Int) { + // BAD: all multiplications run on every recomposition — should be remember { }. + val views = id * 317 + 1_200 + val shares = id * 41 + 80 + val comments = id * 13 + 25 + val reposts = id * 7 + 10 + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Post stats", style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text("👁 $views views") + Text("🔁 $reposts reposts") + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text("💬 $comments comments") + Text("📤 $shares shares") + } + } +} + +@Composable +private fun DetailRelatedSection(sourceId: Int) { + // BAD: List allocation on every recomposition — should be remember { }. + val relatedIds = List(8) { i -> (sourceId + i + 1) % 220 } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Related posts", style = MaterialTheme.typography.titleSmall) + // BAD: no key {} on forEach — Compose cannot identify items across recompositions, + // so it may recompose all children even when only one changes. + relatedIds.forEach { relatedId -> + DetailRelatedItem( + imageUrl = "https://picsum.photos/seed/$relatedId/60/60", + title = "Related Post #$relatedId", + category = "Category ${relatedId % 10}", + ) + } + } +} + +@Composable +private fun DetailRelatedItem(imageUrl: String, title: String, category: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(6.dp)), + ) + Column { + Text(title, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) + Text(category, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + } + } +} + +@Composable +private fun DetailAuthorCard(author: String, id: Int) { + // BAD: Color object allocated inline — should be a top-level constant or remember { }. + val avatarColor = Color(0xFF1565C0) + + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(12.dp)) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // BAD: split + map + joinToString to derive initials on every recomposition. + val initials = author.split(" ") + .mapNotNull { it.firstOrNull()?.toString() } + .joinToString("") + + Box( + modifier = Modifier + .size(40.dp) + .background(avatarColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text(text = initials, color = Color.White, style = MaterialTheme.typography.labelLarge) + } + Column { + Text(author, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + // BAD: inline modulo + addition on every recomposition. + Text("${id % 50 + 5} posts published", style = MaterialTheme.typography.labelSmall) + } + } +} diff --git a/ui/src/main/java/com/aquib/androidperflab/ui/FeedItem.kt b/ui/src/main/java/com/aquib/androidperflab/ui/FeedItem.kt new file mode 100644 index 0000000..d6b7daf --- /dev/null +++ b/ui/src/main/java/com/aquib/androidperflab/ui/FeedItem.kt @@ -0,0 +1,11 @@ +package com.aquib.androidperflab.ui + +data class FeedItem( + val id: Int, + val title: String, + val subtitle: String, + val description: String, + val author: String, + val imageUrl: String, + val timestampMillis: Long, +) diff --git a/ui/src/main/java/com/aquib/androidperflab/ui/FeedScreen.kt b/ui/src/main/java/com/aquib/androidperflab/ui/FeedScreen.kt new file mode 100644 index 0000000..f87d857 --- /dev/null +++ b/ui/src/main/java/com/aquib/androidperflab/ui/FeedScreen.kt @@ -0,0 +1,94 @@ +package com.aquib.androidperflab.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val CATEGORIES = listOf( + "Technology", "Science", "Health", "Travel", "Food", + "Sports", "Art", "Music", "Business", "Education", +) + +private fun generateFeedItems(count: Int = 220): List = List(count) { i -> + FeedItem( + id = i, + title = "Post #$i — ${CATEGORIES[i % CATEGORIES.size]}", + subtitle = "${CATEGORIES[i % CATEGORIES.size]} · ${i % 8 + 1} min read", + description = "This is the body of feed item $i. It contains enough text to " + + "simulate a real article excerpt that would appear in a social feed.", + author = "Author ${i % 25}", + imageUrl = "https://picsum.photos/seed/$i/200/200", + timestampMillis = System.currentTimeMillis() - i * 3_600_000L, + ) +} + +@Composable +fun FeedScreen( + modifier: Modifier = Modifier, + onItemClick: (FeedItem) -> Unit = {}, +) { + val items = remember { generateFeedItems() } + LazyColumn(modifier = modifier.fillMaxSize()) { + items(items = items, key = { it.id }) { item -> + FeedItemRow(item = item, onClick = { onItemClick(item) }) + HorizontalDivider() + } + } +} + +@Composable +private fun FeedItemRow(item: FeedItem, onClick: () -> Unit = {}) { + // Intentionally unoptimized: a new SimpleDateFormat is allocated and a new Date is + // constructed on every recomposition instead of being computed once with remember {}. + val timestamp = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.getDefault()) + .format(Date(item.timestampMillis)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AsyncImage( + model = item.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)), + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(text = item.title, style = MaterialTheme.typography.titleSmall) + Text(text = item.subtitle, style = MaterialTheme.typography.labelMedium) + Text( + text = item.description, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + ) + Text(text = "by ${item.author}", style = MaterialTheme.typography.labelSmall) + Text(text = timestamp, style = MaterialTheme.typography.labelSmall) + } + } +} diff --git a/ui/src/main/java/com/aquib/androidperflab/ui/HomeScreen.kt b/ui/src/main/java/com/aquib/androidperflab/ui/HomeScreen.kt index 64d78a3..2cce5ae 100644 --- a/ui/src/main/java/com/aquib/androidperflab/ui/HomeScreen.kt +++ b/ui/src/main/java/com/aquib/androidperflab/ui/HomeScreen.kt @@ -1,20 +1,51 @@ package com.aquib.androidperflab.ui +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp @Composable fun HomeScreen(modifier: Modifier = Modifier) { Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - Box(contentAlignment = Alignment.Center) { - Text(text = "AndroidPerfLab", style = MaterialTheme.typography.headlineMedium) + var selectedItem by remember { mutableStateOf(null) } + var showAnimatedList by remember { mutableStateOf(false) } + + when { + selectedItem != null -> { + BackHandler { selectedItem = null } + DetailScreen(item = selectedItem!!, onBack = { selectedItem = null }) + } + showAnimatedList -> { + BackHandler { showAnimatedList = false } + AnimatedListScreen(onBack = { showAnimatedList = false }) + } + else -> { + Box(modifier = Modifier.fillMaxSize()) { + FeedScreen(onItemClick = { selectedItem = it }) + FloatingActionButton( + onClick = { showAnimatedList = true }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Text("▶") + } + } + } } } }