feat(about): add "View changes" button with detailed commit history (#5711)

* feat(about): add "View changes" button with detailed commit history

Add a new "View changes" button in the About screen that displays a detailed
changelog dialog when updates are available. This provides users with better
visibility into what changes they will receive when updating.

Features added:
- New "View changes" button in update section (Available/Downloaded states)
- ChangesDialog component showing commit history since current version
- Clickable commit cards that open GitHub commit pages in browser
- Relative time display (5m, 2h, 3d, etc.) following GitHub's format
- Full internationalization support for time strings
- Fetches commits using GitHub API and compares with current build
- Clean Material 3 UI with proper styling and animations

Technical changes:
- Extended GitHubService with commits and compare endpoints
- Added GitHubCommit and GitHubCompareResponse data models
- Enhanced NightlyBuildsRepository with commit fetching capabilities
- Added time-relative string resources (time_minutes_ago, time_hours_ago, etc.)
- Updated AboutViewModel to handle changes dialog state
- Modified UpdateState models to include build numbers
- Renamed getCommits to getRepositoryCommits for API consistency
- Replaced all hardcoded strings with proper internationalized resources

All strings are now properly internationalized to ensure translatability
across all supported languages.

* style: apply spotless code formatting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(updater): move changelog logic to ViewModel

* feat(updater): improve UI and UX

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: SuperDragonXD <70206496+SuperDragonXD@users.noreply.github.com>
This commit is contained in:
foXaCe
2025-08-07 13:35:54 +02:00
committed by GitHub
parent a2236c6945
commit 24cb0d46e1
8 changed files with 406 additions and 27 deletions
+17
View File
@@ -54,8 +54,25 @@
<string name="download_update">Download update</string>
<string name="install_update">Install update</string>
<string name="view_changes">View changes</string>
<string name="pro_updated">You\'re up-to-date!</string>
<!-- Changes dialog strings -->
<string name="changes_dialog_title">Updates</string>
<string name="changes_dialog_error">Failed to load changes</string>
<string name="changes_dialog_no_changes">No changes found</string>
<string name="changes_dialog_build_format">Build #%1$d → #%2$d</string>
<string name="changes_dialog_commit_info">by %1$s • %2$s • %3$s</string>
<!-- Time ago strings -->
<string name="time_just_now">just now</string>
<string name="time_minutes_ago">%dm</string>
<string name="time_hours_ago">%dh</string>
<string name="time_days_ago">%dd</string>
<string name="time_weeks_ago">%dw</string>
<string name="time_months_ago">%dmo</string>
<string name="time_years_ago">%dy</string>
<string name="managed_by_lawnchair">Managed by Lawnchair</string>
<!-- When mentioning settings UI -->
@@ -28,10 +28,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
@@ -53,7 +59,9 @@ import app.lawnchair.ui.preferences.components.layout.preferenceGroupItems
import app.lawnchair.ui.preferences.navigation.AboutLicenses
import com.android.launcher3.BuildConfig
import com.android.launcher3.R
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun About(
modifier: Modifier = Modifier,
@@ -62,6 +70,30 @@ fun About(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(true)
var openBottomSheet by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (openBottomSheet) {
val updateState = uiState.updateState
if (updateState is UpdateState.Available) {
ChangesDialog(
changelogState = updateState.changelogState,
onDismiss = {
scope.launch {
sheetState.hide()
}.invokeOnCompletion {
openBottomSheet = false
}
},
onDownload = {
viewModel.downloadUpdate()
},
sheetState = sheetState,
)
}
}
PreferenceLayoutLazyColumn(
label = stringResource(id = R.string.about_label),
modifier = modifier,
@@ -125,7 +157,15 @@ fun About(
item {
UpdateSection(
updateState = uiState.updateState,
onEvent = viewModel::onEvent,
onInstall = {
viewModel.installUpdate(it)
},
onViewChanges = {
openBottomSheet = true
scope.launch {
sheetState.show()
}
},
)
}
item {
@@ -104,7 +104,7 @@ sealed interface UpdateState {
* @param name The name of the available update (used in `Available` state).
* @param url The URL to download the update from (used in `Available` state).
*/
data class Available(val name: String, val url: String) : UpdateState
data class Available(val name: String, val url: String, val changelogState: ChangelogState?) : UpdateState
/**
* An update is currently being downloaded. Contains the download progress.
@@ -122,16 +122,11 @@ sealed interface UpdateState {
data object Failed : UpdateState
}
/**
* Sealed interface representing user actions that can be triggered from the "About" screen.
*
* This interface defines the different types of events related to user interactions,
* such as initiating a download or an installation.
*/
sealed interface AboutEvent {
data object OnDownloadClicked : AboutEvent
data class OnInstallClicked(val file: File) : AboutEvent
}
data class ChangelogState(
val commits: List<GitHubCommit> = emptyList(),
val currentBuildNumber: Int = 0,
val latestBuildNumber: Int = 0,
)
/**
* Represents the status of a contributor.
@@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.launcher3.BuildConfig
import com.android.launcher3.R
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -16,9 +17,11 @@ class AboutViewModel(
application: Application,
) : AndroidViewModel(application) {
private val api: GitHubService = gitHubApiRetrofit.create()
private val nightlyBuildsRepository = NightlyBuildsRepository(
applicationContext = application,
api = gitHubApiRetrofit.create(),
api = api,
)
private val _uiState = MutableStateFlow(AboutUiState())
@@ -56,11 +59,12 @@ class AboutViewModel(
}
}
fun onEvent(event: AboutEvent) {
when (event) {
is AboutEvent.OnDownloadClicked -> nightlyBuildsRepository.downloadUpdate()
is AboutEvent.OnInstallClicked -> nightlyBuildsRepository.installUpdate(event.file)
}
fun downloadUpdate() {
nightlyBuildsRepository.downloadUpdate()
}
fun installUpdate(file: File) {
nightlyBuildsRepository.installUpdate(file)
}
private suspend fun fetchActiveContributors(): Set<String> {
@@ -70,6 +74,7 @@ class AboutViewModel(
.toSet()
}.getOrDefault(emptySet())
}
companion object {
private val team = listOf(
TeamMember(
@@ -0,0 +1,219 @@
package app.lawnchair.ui.preferences.about
import android.content.Context
import android.content.Intent
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import app.lawnchair.ui.preferences.components.layout.PreferenceGroupItem
import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
import com.android.launcher3.R
import java.time.Instant
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangesDialog(
changelogState: ChangelogState?,
sheetState: SheetState,
onDismiss: () -> Unit,
onDownload: () -> Unit,
) {
val commits = changelogState?.commits
val currentBuild = changelogState?.currentBuildNumber ?: 0
val latestBuild = changelogState?.latestBuildNumber ?: 0
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
) {
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)) {
Text(
text = stringResource(R.string.changes_dialog_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(
R.string.changes_dialog_build_format,
currentBuild,
latestBuild,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
)
}
LazyColumn(
modifier = Modifier
.heightIn(max = 600.dp)
.fillMaxWidth(),
) {
if (commits != null) {
itemsIndexed(commits) { index, commit ->
PreferenceGroupItem(
cutTop = index != 0,
cutBottom = index != commits.lastIndex,
) {
CommitItem(commit = commit)
}
Spacer(Modifier.height(3.dp))
}
} else {
item {
Text(
text = stringResource(R.string.changes_dialog_error),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
) {
OutlinedButton(
onClick = onDismiss,
) {
Text(text = stringResource(android.R.string.cancel))
}
Spacer(Modifier.width(8.dp))
Button(
onClick = {
onDownload()
onDismiss()
},
) {
Text(text = stringResource(R.string.download_update))
}
}
}
}
@Composable
private fun CommitItem(
commit: GitHubCommit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val message = commit.commit.message
val title = message.substringBefore("\n").take(100)
val description = message.substringAfter("\n", "").take(200)
PreferenceTemplate(
title = {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
},
description = {
if (description.isNotEmpty()) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(4.dp))
}
val timeAgo = getTimeAgo(context, commit.commit.author.date)
Text(
text = stringResource(
R.string.changes_dialog_commit_info,
commit.commit.author.name,
commit.sha.take(7),
timeAgo,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
},
modifier = modifier.clickable {
openCommitInBrowser(context, commit.sha)
},
)
}
private fun openCommitInBrowser(context: Context, commitSha: String) {
val commitUrl = "https://github.com/LawnchairLauncher/lawnchair/commit/$commitSha"
val intent = Intent(Intent.ACTION_VIEW, commitUrl.toUri())
context.startActivity(intent)
}
private fun getTimeAgo(context: Context, dateString: String): String {
return try {
val commitDate = Instant.parse(dateString)
val currentTime = Instant.now()
val diffMillis = currentTime.toEpochMilli() - commitDate.toEpochMilli()
when {
diffMillis < TimeUnit.MINUTES.toMillis(1) -> {
context.getString(R.string.time_just_now)
}
diffMillis < TimeUnit.HOURS.toMillis(1) -> {
val minutes = TimeUnit.MILLISECONDS.toMinutes(diffMillis)
context.getString(R.string.time_minutes_ago, minutes)
}
diffMillis < TimeUnit.DAYS.toMillis(1) -> {
val hours = TimeUnit.MILLISECONDS.toHours(diffMillis)
context.getString(R.string.time_hours_ago, hours)
}
diffMillis < TimeUnit.DAYS.toMillis(7) -> {
val days = TimeUnit.MILLISECONDS.toDays(diffMillis)
context.getString(R.string.time_days_ago, days)
}
diffMillis < TimeUnit.DAYS.toMillis(30) -> {
val weeks = TimeUnit.MILLISECONDS.toDays(diffMillis) / 7
context.getString(R.string.time_weeks_ago, weeks)
}
diffMillis < TimeUnit.DAYS.toMillis(365) -> {
val months = TimeUnit.MILLISECONDS.toDays(diffMillis) / 30
context.getString(R.string.time_months_ago, months)
}
else -> {
val years = TimeUnit.MILLISECONDS.toDays(diffMillis) / 365
context.getString(R.string.time_years_ago, years)
}
}
} catch (e: Exception) {
// If date parsing fails, fallback to date prefix
dateString.substringBefore("T")
}
}
@@ -28,6 +28,20 @@ interface GitHubService {
@Path("repo") repo: String,
): List<GitHubEvent>
@GET("repos/{owner}/{repo}/commits")
suspend fun getRepositoryCommits(
@Path("owner") owner: String,
@Path("repo") repo: String,
): List<GitHubCommit>
@GET("repos/{owner}/{repo}/compare/{base}...{head}")
suspend fun compareCommits(
@Path("owner") owner: String,
@Path("repo") repo: String,
@Path("base") base: String,
@Path("head") head: String,
): GitHubCompareResponse
@Streaming
@GET
suspend fun downloadFile(@Url fileUrl: String): ResponseBody
@@ -84,6 +98,53 @@ data class GitHubEvent(
)
}
/**
* Represents a GitHub commit.
*
* @property sha The SHA hash of the commit.
* @property commit The commit details.
* @property author The author of the commit.
*/
@Serializable
data class GitHubCommit(
val sha: String,
val commit: CommitDetails,
val author: Author? = null,
) {
@Serializable
data class CommitDetails(
val message: String,
val author: CommitAuthor,
)
@Serializable
data class CommitAuthor(
val name: String,
val date: String,
)
@Serializable
data class Author(
val login: String,
)
}
/**
* Represents a GitHub comparison response between two commits.
*
* @property commits List of commits between the base and head.
* @property aheadBy Number of commits ahead.
* @property behindBy Number of commits behind.
*/
@Serializable
data class GitHubCompareResponse(
val commits: List<GitHubCommit>,
@SerialName("ahead_by")
val aheadBy: Int,
@SerialName("behind_by")
val behindBy: Int,
)
internal val gitHubApiRetrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
@@ -30,6 +30,10 @@ class NightlyBuildsRepository(
private val _updateState = MutableStateFlow<UpdateState>(UpdateState.UpToDate)
val updateState = _updateState.asStateFlow()
private var currentBuildNumber: Int = 0
private var latestBuildNumber: Int = 0
private var currentCommitHash: String = BuildConfig.COMMIT_HASH
fun checkForUpdate() {
coroutineScope.launch(Dispatchers.Default) {
_updateState.update { UpdateState.Checking }
@@ -42,18 +46,29 @@ class NightlyBuildsRepository(
// <major>.<branch>.(#<CI build number>)
// This is done inside build.gradle in the source root. Reflect
// changes from there if needed.
val currentVersion = BuildConfig.VERSION_DISPLAY_NAME
currentBuildNumber = BuildConfig.VERSION_DISPLAY_NAME
.substringAfterLast("#")
.removeSuffix(")")
.toIntOrNull() ?: 0
val latestVersion =
latestBuildNumber =
asset?.name?.substringAfter("_")?.substringBefore("-")?.toIntOrNull() ?: 0
if (asset != null && latestVersion > currentVersion) {
if (asset != null && latestBuildNumber > currentBuildNumber) {
val commitList = getCommitsSinceCurrentVersion()
_updateState.update {
UpdateState.Available(
asset.name,
asset.browserDownloadUrl,
changelogState = if (commitList != null) {
ChangelogState(
commits = commitList,
currentBuildNumber = currentBuildNumber,
latestBuildNumber = latestBuildNumber,
)
} else {
null
},
)
}
} else {
@@ -114,6 +129,27 @@ class NightlyBuildsRepository(
applicationContext.startActivity(intent)
}
private suspend fun getCommitsSinceCurrentVersion(): List<GitHubCommit>? {
return try {
// Get the latest commits (last 100)
val commits = api.getRepositoryCommits("LawnchairLauncher", "lawnchair")
// Find the index of current commit
val currentIndex = commits.indexOfFirst { it.sha.startsWith(currentCommitHash) }
if (currentIndex > 0) {
// Return all commits newer than current version
commits.take(currentIndex)
} else {
// If current commit not found, show last N commits
commits.take(MAX_FALLBACK_COMMITS)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to get commits", e)
null
}
}
private suspend fun downloadApk(url: String, onProgress: (Float) -> Unit): File? {
return try {
val cacheDir = applicationContext.cacheDir
@@ -170,3 +206,5 @@ private fun Context.requestInstallPermission() {
startActivity(intent)
}
}
private const val MAX_FALLBACK_COMMITS = 30
@@ -14,11 +14,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.android.launcher3.R
import java.io.File
@Composable
fun UpdateSection(
updateState: UpdateState,
onEvent: (AboutEvent) -> Unit,
onViewChanges: () -> Unit,
onInstall: (File) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@@ -40,8 +42,7 @@ fun UpdateSection(
}
is UpdateState.Available -> {
Button(
onClick = { onEvent(AboutEvent.OnDownloadClicked) },
modifier = Modifier.padding(top = 8.dp),
onClick = onViewChanges,
) {
Text(text = stringResource(R.string.download_update))
}
@@ -49,7 +50,9 @@ fun UpdateSection(
is UpdateState.Downloading -> {
LinearProgressIndicator(
progress = { updateState.progress },
modifier = Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
Text(
text = "${(updateState.progress * 100).toInt()}%",
@@ -58,8 +61,9 @@ fun UpdateSection(
}
is UpdateState.Downloaded -> {
Button(
onClick = { onEvent(AboutEvent.OnInstallClicked(updateState.file)) },
modifier = Modifier.padding(top = 8.dp),
onClick = {
onInstall(updateState.file)
},
) {
Text(text = stringResource(R.string.install_update))
}