diff --git a/basic/src/main/AndroidManifest.xml b/basic/src/main/AndroidManifest.xml
index 12044e4a3..f779415c6 100644
--- a/basic/src/main/AndroidManifest.xml
+++ b/basic/src/main/AndroidManifest.xml
@@ -3,7 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
-
+
@@ -31,8 +31,7 @@
-
+ tools:node="remove" />
diff --git a/basic/src/main/java/org/fdroid/basic/App.kt b/basic/src/main/java/org/fdroid/basic/App.kt
index 10afa220f..4af5014d5 100644
--- a/basic/src/main/java/org/fdroid/basic/App.kt
+++ b/basic/src/main/java/org/fdroid/basic/App.kt
@@ -15,6 +15,8 @@ import coil3.request.crossfade
import coil3.util.DebugLogger
import dagger.hilt.android.HiltAndroidApp
import org.fdroid.basic.repo.RepoUpdateWorker
+import org.fdroid.basic.ui.icons.ApplicationIconFetcher
+import org.fdroid.basic.ui.icons.PackageName
import org.fdroid.download.DownloadRequest
import org.fdroid.download.coil.DownloadRequestFetcher
import javax.inject.Inject
@@ -24,6 +26,7 @@ class App : Application(), Configuration.Provider, SingletonImageLoader.Factory
@Inject
lateinit var workerFactory: HiltWorkerFactory
+
@Inject
lateinit var downloadRequestFetcherFactory: DownloadRequestFetcher.Factory
@@ -41,7 +44,7 @@ class App : Application(), Configuration.Provider, SingletonImageLoader.Factory
return ImageLoader.Builder(context)
.crossfade(true)
.components {
- val keyer = object : Keyer {
+ val downloadRequestKeyer = object : Keyer {
override fun key(
data: DownloadRequest,
options: Options
@@ -50,8 +53,14 @@ class App : Application(), Configuration.Provider, SingletonImageLoader.Factory
?: (data.mirrors[0].baseUrl + data.indexFile.name)
}
}
- add(keyer)
+ add(downloadRequestKeyer)
add(downloadRequestFetcherFactory)
+
+ val packageNameKeyer = object : Keyer {
+ override fun key(data: PackageName, options: Options): String = data.packageName
+ }
+ add(packageNameKeyer)
+ add(ApplicationIconFetcher.Factory(this@App.applicationContext, downloadRequestFetcherFactory))
}
.memoryCache {
MemoryCache.Builder()
diff --git a/basic/src/main/java/org/fdroid/basic/MainViewModel.kt b/basic/src/main/java/org/fdroid/basic/MainViewModel.kt
index a087f8bf1..717102bae 100644
--- a/basic/src/main/java/org/fdroid/basic/MainViewModel.kt
+++ b/basic/src/main/java/org/fdroid/basic/MainViewModel.kt
@@ -16,19 +16,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
-import org.fdroid.basic.details.AppDetailsManager
import org.fdroid.basic.download.getDownloadRequest
-import org.fdroid.basic.manager.MyAppsManager
import org.fdroid.basic.repo.RepositoryManager
import org.fdroid.basic.ui.categories.Category
-import org.fdroid.basic.ui.main.apps.MinimalApp
import org.fdroid.basic.ui.main.discover.AppNavigationItem
import org.fdroid.basic.ui.main.discover.DiscoverModel
import org.fdroid.basic.ui.main.discover.DiscoverPresenter
+import org.fdroid.basic.ui.main.lists.AppList
import org.fdroid.basic.ui.main.lists.FilterModel
import org.fdroid.basic.ui.main.lists.FilterPresenter
import org.fdroid.basic.ui.main.lists.Sort
-import org.fdroid.basic.ui.main.lists.AppList
import org.fdroid.database.FDroidDatabase
import java.text.Collator
import java.util.Locale
@@ -38,9 +35,7 @@ import javax.inject.Inject
class MainViewModel @Inject constructor(
app: Application,
savedStateHandle: SavedStateHandle,
- val myAppsManager: MyAppsManager,
- private val db: FDroidDatabase,
- private val appDetailsManager: AppDetailsManager,
+ db: FDroidDatabase,
val repositoryManager: RepositoryManager,
) : AndroidViewModel(app) {
@@ -97,19 +92,11 @@ class MainViewModel @Inject constructor(
addedCategoriesFlow = addedCategories,
)
}
- val updates = myAppsManager.updates
- val installed = myAppsManager.installed
- val numUpdates = myAppsManager.numUpdates
- val appDetails = appDetailsManager.appDetails
fun setAppList(appList: AppList) {
_currentList.value = appList
}
- fun setAppDetails(app: MinimalApp) {
- appDetailsManager.setAppDetails(app)
- }
-
fun toggleListFilterVisibility() {
_showFilters.update { !it }
}
diff --git a/basic/src/main/java/org/fdroid/basic/details/AppDetailsItem.kt b/basic/src/main/java/org/fdroid/basic/details/AppDetailsItem.kt
index 2171e9c6b..b0865296e 100644
--- a/basic/src/main/java/org/fdroid/basic/details/AppDetailsItem.kt
+++ b/basic/src/main/java/org/fdroid/basic/details/AppDetailsItem.kt
@@ -12,6 +12,7 @@ import org.fdroid.database.AppPrefs
import org.fdroid.database.AppVersion
import org.fdroid.database.Repository
import org.fdroid.download.DownloadRequest
+import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.v2.PackageManifest
import org.fdroid.index.v2.PackageVersion
import org.fdroid.index.v2.SignerV2
@@ -21,8 +22,19 @@ enum class MainButtonState {
NONE, INSTALL, UPDATE
}
+class AppDetailsActions(
+ val allowBetaVersions: () -> Unit,
+ val ignoreAllUpdates: (() -> Unit)? = null,
+ val ignoreThisUpdate: (() -> Unit)? = null,
+ val shareApk: (() -> Unit)? = null,
+ val uninstallApp: (() -> Unit)? = null,
+ val launchIntent: Intent? = null,
+ val shareIntent: Intent? = null,
+)
+
data class AppDetailsItem(
val app: AppMetadata,
+ val actions: AppDetailsActions,
/**
* The ID of the repo that is currently set as preferred.
* Note that the repository ID of this [app] may be different.
@@ -45,11 +57,18 @@ data class AppDetailsItem(
* Needed, because the [installedVersion] may not be available, e.g. too old.
*/
val installedVersionCode: Long? = null,
+ /**
+ * The currently suggested version for installation.
+ */
val suggestedVersion: PackageVersion? = null,
+ /**
+ * Similar to [suggestedVersion], but doesn't obey [appPrefs] for ignoring versions.
+ * This is useful for (un-)ignoring this version.
+ */
+ val possibleUpdate: PackageVersion? = null,
val appPrefs: AppPrefs? = null,
val whatsNew: String? = null,
val antiFeatures: List? = null,
- val launchIntent: Intent? = null,
/**
* true if this app from this repository has no versions with a
* compatible signer. This means that the app is installed, but does not receive updates either
@@ -63,17 +82,19 @@ data class AppDetailsItem(
preferredRepoId: Long,
repositories: List,
dbApp: App,
+ actions: AppDetailsActions,
versions: List?,
installedVersion: AppVersion?,
installedVersionCode: Long?,
suggestedVersion: AppVersion?,
+ possibleUpdate: AppVersion?,
appPrefs: AppPrefs?,
- launchIntent: Intent?,
noCompatibleVersions: Boolean,
authorHasMoreThanOneApp: Boolean,
localeList: LocaleListCompat,
) : this(
app = dbApp.metadata,
+ actions = actions,
preferredRepoId = preferredRepoId,
repositories = repositories,
name = dbApp.name ?: "Unknown App",
@@ -93,12 +114,12 @@ data class AppDetailsItem(
installedVersion = installedVersion,
installedVersionCode = installedVersionCode,
suggestedVersion = suggestedVersion,
+ possibleUpdate = possibleUpdate,
appPrefs = appPrefs,
whatsNew = installedVersion?.getWhatsNew(localeList),
antiFeatures = installedVersion?.getAntiFeatures(repository, localeList)
?: suggestedVersion?.getAntiFeatures(repository, localeList)
?: versions?.first()?.getAntiFeatures(repository, localeList),
- launchIntent = launchIntent,
noCompatibleVersions = noCompatibleVersions,
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
)
@@ -107,7 +128,23 @@ data class AppDetailsItem(
* True if the app is installed (and has a launch intent)
* and thus the 'Open' button should be shown.
*/
- val showOpenButton: Boolean get() = launchIntent != null
+ val showOpenButton: Boolean get() = actions.launchIntent != null
+ val allowsBetaVersions: Boolean
+ get() = appPrefs?.releaseChannels?.contains(RELEASE_CHANNEL_BETA) == true
+
+ val ignoresAllUpdates: Boolean get() = appPrefs?.ignoreAllUpdates == true
+
+ /**
+ * True if the update from [possibleUpdate] is being ignored
+ * and not already ignoring all updates anyway.
+ */
+ val ignoresCurrentUpdate: Boolean
+ get() {
+ if (ignoresAllUpdates) return false
+ val prefs = appPrefs ?: return false
+ val updateVersionCode = possibleUpdate?.versionCode ?: return false
+ return actions.ignoreThisUpdate != null && prefs.shouldIgnoreUpdate(updateVersionCode)
+ }
/**
* Specifies what main button should be shown.
@@ -244,6 +281,8 @@ val testApp = AppDetailsItem(
categories = listOf("Internet", "Multimedia"),
isCompatible = true,
),
+ actions = AppDetailsActions({}, {}, {}, {}, {}, Intent(), Intent()),
+ appPrefs = AppPrefs("org.schabi.newpipe"),
name = "New Pipe",
summary = "Lightweight YouTube frontend",
description = "NewPipe does not use any Google framework libraries, or the YouTube API. " +
@@ -277,4 +316,5 @@ val testApp = AppDetailsItem(
),
installedVersion = testVersion2,
suggestedVersion = testVersion1,
+ possibleUpdate = testVersion1,
)
diff --git a/basic/src/main/java/org/fdroid/basic/details/AppDetailsManager.kt b/basic/src/main/java/org/fdroid/basic/details/AppDetailsViewModel.kt
similarity index 51%
rename from basic/src/main/java/org/fdroid/basic/details/AppDetailsManager.kt
rename to basic/src/main/java/org/fdroid/basic/details/AppDetailsViewModel.kt
index ead848ede..ee6237c44 100644
--- a/basic/src/main/java/org/fdroid/basic/details/AppDetailsManager.kt
+++ b/basic/src/main/java/org/fdroid/basic/details/AppDetailsViewModel.kt
@@ -1,34 +1,37 @@
package org.fdroid.basic.details
-import android.content.Context
+import android.app.Application
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES
+import androidx.annotation.UiThread
+import androidx.lifecycle.AndroidViewModel
import app.cash.molecule.RecompositionMode.Immediate
import app.cash.molecule.launchMolecule
-import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.fdroid.UpdateChecker
-import org.fdroid.basic.ui.main.apps.MinimalApp
+import org.fdroid.basic.manager.MyAppsManager
import org.fdroid.basic.utils.IoDispatcher
import org.fdroid.database.FDroidDatabase
+import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.RepoManager
import javax.inject.Inject
-import javax.inject.Singleton
-@Singleton
-class AppDetailsManager @Inject constructor(
- @ApplicationContext private val context: Context,
+@HiltViewModel
+class AppDetailsViewModel @Inject constructor(
+ private val app: Application,
@IoDispatcher private val scope: CoroutineScope,
private val db: FDroidDatabase,
private val repoManager: RepoManager,
private val updateChecker: UpdateChecker,
-) {
+ private val myAppsManager: MyAppsManager,
+) : AndroidViewModel(app) {
private val packageInfoFlow = MutableStateFlow(null)
val appDetails: StateFlow = scope.launchMolecule(
@@ -38,27 +41,56 @@ class AppDetailsManager @Inject constructor(
db = db,
repoManager = repoManager,
updateChecker = updateChecker,
+ viewModel = this,
packageInfoFlow = packageInfoFlow,
)
}
- fun setAppDetails(minimalApp: MinimalApp?) {
+ fun setAppDetails(packageName: String) {
packageInfoFlow.value = null
- val packageManager = context.packageManager
- if (minimalApp != null) scope.launch {
+ val packageManager = app.packageManager
+ scope.launch {
val packageInfo = try {
- packageManager.getPackageInfo(minimalApp.packageName, GET_SIGNATURES)
+ packageManager.getPackageInfo(packageName, GET_SIGNATURES)
} catch (_: PackageManager.NameNotFoundException) {
null
}
packageInfoFlow.value = if (packageInfo == null) {
- AppInfo(minimalApp.packageName)
+ AppInfo(packageName)
} else {
- val intent = packageManager.getLaunchIntentForPackage(minimalApp.packageName)
- AppInfo(minimalApp.packageName, packageInfo, intent)
+ val intent = packageManager.getLaunchIntentForPackage(packageName)
+ AppInfo(packageName, packageInfo, intent)
}
}
}
+
+ @UiThread
+ fun allowBetaUpdates() {
+ val appPrefs = appDetails.value?.appPrefs ?: return
+ scope.launch {
+ db.getAppPrefsDao().update(appPrefs.toggleReleaseChannel(RELEASE_CHANNEL_BETA))
+ myAppsManager.loadUpdates()
+ }
+ }
+
+ @UiThread
+ fun ignoreAllUpdates() {
+ val appPrefs = appDetails.value?.appPrefs ?: return
+ scope.launch {
+ db.getAppPrefsDao().update(appPrefs.toggleIgnoreAllUpdates())
+ myAppsManager.loadUpdates()
+ }
+ }
+
+ @UiThread
+ fun ignoreThisUpdate() {
+ val appPrefs = appDetails.value?.appPrefs ?: return
+ val versionCode = appDetails.value?.possibleUpdate?.versionCode ?: return
+ scope.launch {
+ db.getAppPrefsDao().update(appPrefs.toggleIgnoreVersionCodeUpdate(versionCode))
+ myAppsManager.loadUpdates()
+ }
+ }
}
class AppInfo(
diff --git a/basic/src/main/java/org/fdroid/basic/details/DetailsPresenter.kt b/basic/src/main/java/org/fdroid/basic/details/DetailsPresenter.kt
index 628c6d26b..46ad81710 100644
--- a/basic/src/main/java/org/fdroid/basic/details/DetailsPresenter.kt
+++ b/basic/src/main/java/org/fdroid/basic/details/DetailsPresenter.kt
@@ -1,10 +1,12 @@
package org.fdroid.basic.details
+import android.content.Intent
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.core.content.pm.PackageInfoCompat.getLongVersionCode
+import androidx.core.net.toUri
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.asFlow
import kotlinx.coroutines.flow.StateFlow
@@ -22,6 +24,7 @@ fun DetailsPresenter(
db: FDroidDatabase,
repoManager: RepoManager,
updateChecker: UpdateChecker,
+ viewModel: AppDetailsViewModel,
packageInfoFlow: StateFlow,
): AppDetailsItem? {
val packagePair = packageInfoFlow.collectAsState().value ?: return null
@@ -47,25 +50,33 @@ fun DetailsPresenter(
updateChecker.getSuggestedVersion(
versions = versions,
preferredSigner = app.metadata.preferredSigner,
- // TODO releaseChannels beta
- releaseChannels = null,
+ releaseChannels = appPrefs.releaseChannels,
preferencesGetter = { appPrefs },
)
}
+ val possibleUpdate = if (versions == null || appPrefs == null) {
+ null
+ } else {
+ updateChecker.getUpdate(
+ versions = versions,
+ allowedSignersGetter = app.metadata.preferredSigner?.let { { setOf(it) } },
+ allowedReleaseChannels = appPrefs.releaseChannels,
+ preferencesGetter = null, // ignoring existing preferences to include ignored versions
+ )
+ }
val installedVersionCode = packagePair.packageInfo?.let {
getLongVersionCode(packagePair.packageInfo)
}
val installedVersion = packagePair.packageInfo?.let {
versions?.find { it.versionCode == installedVersionCode }
}
+ val installedSigner = packagePair.packageInfo?.signatures?.get(0)?.let {
+ sha256(it.toByteArray())
+ }
val noCompatibleVersions = if (packagePair.packageInfo != null && versions != null) {
- // get installed signer
- val signer = packagePair.packageInfo.signatures?.get(0)?.let {
- sha256(it.toByteArray())
- }
// return true of no version has same signer
versions.none { version ->
- version.manifest.signer?.sha256?.get(0) == signer
+ version.manifest.signer?.sha256?.get(0) == installedSigner
}
} else {
false
@@ -84,14 +95,49 @@ fun DetailsPresenter(
preferredRepoId = preferredRepoId,
repositories = repositories, // TODO maybe use emptyList() when only in F-Droid repo
dbApp = app,
+ actions = AppDetailsActions(
+ allowBetaVersions = viewModel::allowBetaUpdates,
+ ignoreAllUpdates = if (installedVersionCode == null) {
+ null
+ } else {
+ viewModel::ignoreAllUpdates
+ },
+ ignoreThisUpdate = if (installedVersionCode == null || possibleUpdate == null ||
+ possibleUpdate.versionCode <= installedVersionCode
+ ) {
+ null
+ } else {
+ viewModel::ignoreThisUpdate
+ },
+ shareApk = null, // TODO
+ uninstallApp = null, // TODO
+ launchIntent = packagePair.launchIntent,
+ shareIntent = getShareIntent(repo, packageName, app.name ?: ""),
+ ),
versions = versions,
installedVersion = installedVersion,
installedVersionCode = installedVersionCode,
suggestedVersion = suggestedVersion,
+ possibleUpdate = possibleUpdate,
appPrefs = appPrefs,
- launchIntent = packagePair.launchIntent,
noCompatibleVersions = noCompatibleVersions,
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
localeList = locales,
)
}
+
+private fun getShareIntent(
+ repo: org.fdroid.database.Repository,
+ packageName: String,
+ appName: String,
+): Intent? {
+ val webBaseUrl = repo.webBaseUrl ?: return null
+ val shareUri = webBaseUrl.toUri().buildUpon().appendPath(packageName).build()
+ val uriIntent = Intent(Intent.ACTION_SEND).apply {
+ setType("text/plain")
+ putExtra(Intent.EXTRA_SUBJECT, appName)
+ putExtra(Intent.EXTRA_TITLE, appName)
+ putExtra(Intent.EXTRA_TEXT, shareUri.toString())
+ }
+ return Intent.createChooser(uriIntent, appName)
+}
diff --git a/basic/src/main/java/org/fdroid/basic/manager/MyAppsManager.kt b/basic/src/main/java/org/fdroid/basic/manager/MyAppsManager.kt
index bbdc7737b..02cb7c615 100644
--- a/basic/src/main/java/org/fdroid/basic/manager/MyAppsManager.kt
+++ b/basic/src/main/java/org/fdroid/basic/manager/MyAppsManager.kt
@@ -1,143 +1,59 @@
package org.fdroid.basic.manager
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.delay
+import androidx.core.os.LocaleListCompat
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import org.fdroid.basic.ui.Icons
-import org.fdroid.basic.ui.Names
-import org.fdroid.basic.ui.main.apps.InstalledApp
-import org.fdroid.basic.ui.main.apps.UpdatableApp
-import org.fdroid.basic.ui.main.lists.Sort
-import java.util.Locale
+import org.fdroid.basic.download.getDownloadRequest
+import org.fdroid.basic.repo.RepositoryManager
+import org.fdroid.basic.utils.IoDispatcher
+import org.fdroid.database.DbUpdateChecker
+import org.fdroid.download.DownloadRequest
+import org.fdroid.index.v2.PackageVersion
import javax.inject.Inject
import javax.inject.Singleton
+data class AppUpdateItem(
+ val packageName: String,
+ val name: String,
+ val installedVersionName: String,
+ val update: PackageVersion,
+ val whatsNew: String?,
+ val iconDownloadRequest: DownloadRequest? = null,
+)
+
@Singleton
-class MyAppsManager @Inject constructor() {
-
- private val _updates = MutableStateFlow>(emptyList())
+class MyAppsManager @Inject constructor(
+ private val dbUpdateChecker: DbUpdateChecker,
+ @IoDispatcher private val coroutineScope: CoroutineScope,
+ private val repositoryManager: RepositoryManager,
+) {
+ private val _updates = MutableStateFlow?>(null)
val updates = _updates.asStateFlow()
- private val _installed = MutableStateFlow>(installedApps)
- val installed = _installed.asStateFlow()
- val numUpdates = _updates.map { it.size }
- private val _sortBy = MutableStateFlow(Sort.NAME)
- val sortBy = _sortBy.asStateFlow()
-
- companion object {
- val installedApps = listOf(
- InstalledApp(
- packageName = "1000",
- name = Names.randomName,
- icon = Icons.randomIcon,
- versionName = "1.0.1",
- ),
- InstalledApp(
- packageName = "1001",
- name = Names.randomName,
- icon = Icons.randomIcon,
- versionName = "0.1",
- ),
- InstalledApp(
- packageName = "1002",
- name = Names.randomName,
- icon = Icons.randomIcon,
- versionName = "3.0.1",
- ),
- InstalledApp(
- packageName = "1003",
- name = Names.randomName,
- icon = Icons.randomIcon,
- versionName = "0.2.1",
- ),
- InstalledApp(
- packageName = "1004",
- name = Names.randomName,
- icon = Icons.randomIcon,
- versionName = "0.0.1",
- ),
- InstalledApp(
- packageName = "1005",
- name = Names.randomName,
- icon = Icons.randomIcon,
- versionName = "1.1.1",
- ),
- InstalledApp(
- packageName = "1006",
- name = Names.randomName,
- icon = Icons.randomIcon,
- versionName = "2.0.1",
- ),
- ).sortedBy { it.name.lowercase(Locale.getDefault()) }
- }
+ private val _numUpdates = MutableStateFlow(0)
+ val numUpdates = _numUpdates.asStateFlow()
init {
- GlobalScope.launch {
- delay(5_000)
- _updates.update {
- listOf(
- UpdatableApp(
- packageName = "2000",
- name = Names.randomName,
- icon = Icons.randomIcon,
- currentVersionName = "1.0.1",
- updateVersionName = "1.1.0",
- size = 123456789,
- whatsNew = "Lots of changes in this version!\nThey are all awesome.\n" +
- "Only the best changes."
- ),
- UpdatableApp(
- packageName = "2001",
- name = Names.randomName,
- icon = Icons.randomIcon,
- currentVersionName = "3.0.1",
- updateVersionName = "3.1.0",
- size = 9876543,
- ),
- UpdatableApp(
- packageName = "2002",
- name = Names.randomName,
- currentVersionName = "4.0.1",
- updateVersionName = "4.3.0",
- size = 4561237,
- whatsNew = "This new version is super fast and aimed at fixing some bugs and enhancing your experience even more. So take the chance to update your app and always enjoy the best of Inter. In addition to the exciting new features in the latest version, we regularly release new versions to improve what you are already using on our app. To keep making your life simpler, keep your app up to date and take advantage of everything we prepare for you. "
- ),
- UpdatableApp(
- packageName = "2003",
- name = Names.randomName,
- icon = Icons.randomIcon,
- currentVersionName = "3.0.1",
- updateVersionName = "3.1.0",
- size = 9876543,
- ),
- ).sortedBy { it.name.lowercase(Locale.getDefault()) }
- }
- }
+ loadUpdates()
}
- fun sortBy(sort: Sort) {
- when (sort) {
- Sort.NAME -> {
- _updates.update {
- it.sortedBy { it.name.lowercase(Locale.getDefault()) }
- }
- _installed.update {
- it.sortedBy { it.name.lowercase(Locale.getDefault()) }
- }
- }
- Sort.LATEST -> {
- _updates.update {
- it.sortedBy { it.packageName }
- }
- _installed.update {
- it.sortedBy { it.packageName }
- }
- }
+ fun loadUpdates() = coroutineScope.launch {
+ // TODO (includeKnownVulnerabilities = true) and show in AppDetails
+ val localeList = LocaleListCompat.getDefault()
+ val updates = dbUpdateChecker.getUpdatableApps(onlyFromPreferredRepo = true).map { update ->
+ AppUpdateItem(
+ packageName = update.packageName,
+ name = update.name ?: "Unknown app",
+ installedVersionName = update.installedVersionName,
+ update = update.update,
+ whatsNew = update.update.getWhatsNew(localeList),
+ iconDownloadRequest = repositoryManager.getRepository(update.repoId)?.let { repo ->
+ update.getIcon(localeList)?.getDownloadRequest(repo)
+ },
+ )
}
- _sortBy.value = sort
+ _updates.value = updates
+ _numUpdates.value = updates.size
}
-
}
diff --git a/basic/src/main/java/org/fdroid/basic/ui/Utils.kt b/basic/src/main/java/org/fdroid/basic/ui/Utils.kt
index e13df4e44..2e0f56645 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/Utils.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/Utils.kt
@@ -1,8 +1,29 @@
package org.fdroid.basic.ui
import android.text.format.DateUtils
+import org.fdroid.index.v2.PackageManifest
+import org.fdroid.index.v2.PackageVersion
+import org.fdroid.index.v2.SignerV2
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
+import java.util.concurrent.TimeUnit
+
+fun getPreviewVersion(versionName: String, size: Long? = null) = object : PackageVersion {
+ override val versionCode: Long = 23
+ override val versionName: String = versionName
+ override val added: Long = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(3)
+ override val size: Long? = size
+ override val signer: SignerV2? = null
+ override val releaseChannels: List? = null
+ override val packageManifest: PackageManifest = object : PackageManifest {
+ override val minSdkVersion: Int? = null
+ override val maxSdkVersion: Int? = null
+ override val featureNames: List? = null
+ override val nativecode: List? = null
+ override val targetSdkVersion: Int? = null
+ }
+ override val hasKnownVulnerability: Boolean = false
+}
fun Long.asRelativeTimeString(): String {
return DateUtils.getRelativeTimeSpanString(
diff --git a/basic/src/main/java/org/fdroid/basic/ui/icons/ApplicationIconFetcher.kt b/basic/src/main/java/org/fdroid/basic/ui/icons/ApplicationIconFetcher.kt
new file mode 100644
index 000000000..be7d717d2
--- /dev/null
+++ b/basic/src/main/java/org/fdroid/basic/ui/icons/ApplicationIconFetcher.kt
@@ -0,0 +1,65 @@
+package org.fdroid.basic.ui.icons
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build.VERSION.SDK_INT
+import coil3.ImageLoader
+import coil3.asImage
+import coil3.decode.DataSource
+import coil3.fetch.FetchResult
+import coil3.fetch.Fetcher
+import coil3.fetch.ImageFetchResult
+import mu.KotlinLogging
+import org.fdroid.download.DownloadRequest
+import org.fdroid.download.coil.DownloadRequestFetcher
+import javax.inject.Inject
+
+data class PackageName(val packageName: String, val iconDownloadRequest: DownloadRequest?)
+
+class ApplicationIconFetcher(
+ private val packageManager: PackageManager,
+ private val data: PackageName,
+ private val downloadRequestFetcher: Fetcher?,
+) : Fetcher {
+
+ private val log = KotlinLogging.logger { }
+
+ override suspend fun fetch(): FetchResult? {
+ val drawable = try {
+ val info = packageManager.getApplicationInfo(data.packageName, 0)
+ info.loadUnbadgedIcon(packageManager)
+ } catch (e: PackageManager.NameNotFoundException) {
+ log.error(e) { "Error getting icon from packageManager: " }
+ return downloadRequestFetcher?.fetch()
+ }
+
+ if (SDK_INT >= 30 && packageManager.isDefaultApplicationIcon(drawable)) {
+ log.warn {
+ "Could not extract image for ${data.packageName}"
+ }
+ return downloadRequestFetcher?.fetch()
+ }
+ return ImageFetchResult(
+ image = drawable.asImage(),
+ isSampled = false,
+ dataSource = DataSource.DISK,
+ )
+ }
+
+ class Factory @Inject constructor(
+ private val context: Context,
+ private val downloadRequestFetcherFactory: DownloadRequestFetcher.Factory,
+ ) : Fetcher.Factory {
+ override fun create(
+ data: PackageName,
+ options: coil3.request.Options,
+ imageLoader: ImageLoader,
+ ): Fetcher? = ApplicationIconFetcher(
+ packageManager = context.packageManager,
+ data = data,
+ downloadRequestFetcher = data.iconDownloadRequest?.let {
+ downloadRequestFetcherFactory.create(it, options, imageLoader)
+ },
+ )
+ }
+}
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/BottomBar.kt b/basic/src/main/java/org/fdroid/basic/ui/main/BottomBar.kt
index 9d2d1a6d7..97f2576b8 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/BottomBar.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/BottomBar.kt
@@ -9,6 +9,7 @@ import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation3.runtime.NavKey
import org.fdroid.basic.ui.BottomNavDestinations
@@ -31,8 +32,13 @@ fun BottomBar(numUpdates: Int, currentNavKey: NavKey, onNav: (NavigationKey) ->
}
@Composable
-fun NavigationRail(numUpdates: Int, currentNavKey: NavKey, onNav: (NavigationKey) -> Unit) {
- NavigationRail {
+fun NavigationRail(
+ numUpdates: Int,
+ currentNavKey: NavKey,
+ onNav: (NavigationKey) -> Unit,
+ modifier: Modifier,
+) {
+ NavigationRail(modifier) {
BottomNavDestinations.entries.forEach { dest ->
NavigationRailItem(
icon = { NavIcon(dest, numUpdates) },
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt b/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt
index 4698e9020..43fff63dd 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt
@@ -16,6 +16,7 @@ import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -31,9 +32,11 @@ import androidx.navigation3.ui.NavDisplay
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import org.fdroid.basic.MainViewModel
import org.fdroid.basic.R
+import org.fdroid.basic.details.AppDetailsViewModel
import org.fdroid.basic.ui.NavigationKey
import org.fdroid.basic.ui.icons.PackageVariant
import org.fdroid.basic.ui.main.apps.MyApps
+import org.fdroid.basic.ui.main.apps.MyAppsViewModel
import org.fdroid.basic.ui.main.details.AppDetails
import org.fdroid.basic.ui.main.discover.Discover
import org.fdroid.basic.ui.main.lists.AppList
@@ -78,7 +81,7 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
val isBigScreen = remember(windowAdaptiveInfo) {
windowAdaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
}
- val listDetailStrategy = rememberListDetailSceneStrategy()//(directive = directive)
+ val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive)
FDroidContent {
NavDisplay(
backStack = backStack,
@@ -97,7 +100,8 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
Text("No app selected")
},
) {
- val numUpdates = viewModel.numUpdates.collectAsStateWithLifecycle(0).value
+ val myAppsViewModel = hiltViewModel()
+ val numUpdates = myAppsViewModel.numUpdates.collectAsStateWithLifecycle(0).value
Discover(
discoverModel = viewModel.discoverModel.collectAsStateWithLifecycle().value,
onTitleTap = {
@@ -105,7 +109,6 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
backStack.add(NavigationKey.AppList)
},
onAppTap = {
- viewModel.setAppDetails(it)
backStack.add(NavigationKey.AppDetails(it.packageName))
},
onNav = { backStack.add(it) },
@@ -119,31 +122,35 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
Text("No app selected")
},
) {
- val updates = viewModel.updates.collectAsStateWithLifecycle().value
- val installed = viewModel.installed.collectAsStateWithLifecycle().value
- val currentItem = viewModel.appDetails.collectAsStateWithLifecycle().value
+ val myAppsViewModel = hiltViewModel()
+ val myAppsModel =
+ myAppsViewModel.myAppsModel.collectAsStateWithLifecycle().value
MyApps(
- updatableApps = updates,
- installedApps = installed,
- currentItem = currentItem,
- onItemClick = {
- viewModel.setAppDetails(it)
- backStack.add(NavigationKey.AppDetails(it.packageName))
+ myAppsModel = myAppsModel,
+ currentPackageName = if (isBigScreen) {
+ (backStack.last() as? NavigationKey.AppDetails)?.packageName
+ } else null,
+ onAppItemClick = {
+ backStack.add(NavigationKey.AppDetails(it))
},
onNav = { backStack.add(it) },
- sortBy = viewModel.myAppsManager.sortBy.collectAsStateWithLifecycle().value,
+ onRefresh = myAppsViewModel::refresh,
isBigScreen = isBigScreen,
- onSortChanged = viewModel.myAppsManager::sortBy,
+ onSortChanged = myAppsViewModel::changeSortOrder,
)
}
entry(
metadata = ListDetailSceneStrategy.detailPane("appdetails")
) {
- val currentItem = viewModel.appDetails.collectAsStateWithLifecycle().value
- it.packageName // TODO use?
+ val appDetailsViewModel = hiltViewModel()
+ LaunchedEffect(it.packageName) {
+ appDetailsViewModel.setAppDetails(it.packageName)
+ }
AppDetails(
- appItem = currentItem,
- onBackNav = { backStack.removeLastOrNull() },
+ item = appDetailsViewModel.appDetails.collectAsStateWithLifecycle().value,
+ onBackNav = if (isBigScreen) null else {
+ { backStack.removeLastOrNull() }
+ },
modifier = Modifier,
)
}
@@ -165,15 +172,15 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
override fun removeCategory(category: String) =
viewModel.removeCategory(category)
}
- val currentItem = viewModel.appDetails.collectAsStateWithLifecycle().value
AppList(
appList = viewModel.currentList.collectAsStateWithLifecycle().value,
filterInfo = filterInfo,
- currentItem = currentItem,
+ currentPackageName = if (isBigScreen) {
+ (backStack.last() as? NavigationKey.AppDetails)?.packageName
+ } else null,
onBackClicked = { backStack.removeLastOrNull() },
modifier = Modifier,
) {
- viewModel.setAppDetails(it)
backStack.add(NavigationKey.AppDetails(it.packageName))
}
}
@@ -192,7 +199,7 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
onRepositorySelected = {
repositoryManager.setVisibleRepository(it)
backStack.add(NavigationKey.RepoDetails(it.repoId))
- } ,
+ },
onAddRepo = repositoryManager::addRepo,
) {
backStack.removeLastOrNull()
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledApp.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledApp.kt
index 10b0fce51..7c464373e 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledApp.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledApp.kt
@@ -1,18 +1,8 @@
package org.fdroid.basic.ui.main.apps
-import android.os.Parcelable
-import kotlinx.parcelize.Parcelize
-
+// TODO remove
interface MinimalApp {
val packageName: String
val name: String?
val icon: String?
}
-
-@Parcelize
-data class InstalledApp(
- override val packageName: String,
- override val icon: String? = null,
- override val name: String,
- val versionName: String,
-) : MinimalApp, Parcelable
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledAppRow.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledAppRow.kt
index 4e4cf100a..77e33a784 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledAppRow.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledAppRow.kt
@@ -1,57 +1,44 @@
package org.fdroid.basic.ui.main.apps
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Android
-import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
+import org.fdroid.basic.R
import org.fdroid.basic.ui.Names
+import org.fdroid.basic.ui.icons.PackageName
import org.fdroid.fdroid.ui.theme.FDroidContent
@Composable
fun InstalledAppRow(
- app: InstalledApp,
+ app: InstalledAppItem,
isSelected: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
ListItem(
leadingContent = {
- app.icon?.let {
- AsyncImage(
- model = app.icon,
- contentDescription = null,
- modifier = Modifier.size(48.dp),
- )
- } ?: Icon(
- Icons.Filled.Android,
- tint = MaterialTheme.colorScheme.secondary,
+ AsyncImage(
+ model = PackageName(app.packageName, app.iconDownloadRequest),
+ error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
- modifier = Modifier
- .clip(MaterialTheme.shapes.medium)
- .size(48.dp)
- .background(Color.White)
- .padding(8.dp),
+ modifier = Modifier.size(48.dp),
)
},
headlineContent = {
Text(app.name)
},
supportingContent = {
- Text(app.versionName)
+ Text(app.installedVersionName)
},
colors = ListItemDefaults.colors(
containerColor = if (isSelected) {
@@ -68,10 +55,11 @@ fun InstalledAppRow(
@Preview
@Composable
fun InstalledAppRowPreview() {
- val app = InstalledApp(
+ val app = InstalledAppItem(
packageName = "",
name = Names.randomName,
- versionName = "1.0.1",
+ installedVersionName = "1.0.1",
+ lastUpdated = System.currentTimeMillis() - 5000,
)
FDroidContent {
Column {
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyApps.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyApps.kt
index f1ad3a1e5..d98b39652 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyApps.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyApps.kt
@@ -1,7 +1,9 @@
package org.fdroid.basic.ui.main.apps
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -10,16 +12,18 @@ import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
-import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.SortByAlpha
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -30,135 +34,146 @@ 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.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.navigation3.runtime.NavKey
import org.fdroid.basic.R
-import org.fdroid.basic.details.AppDetailsItem
+import org.fdroid.basic.manager.AppUpdateItem
import org.fdroid.basic.ui.Names
import org.fdroid.basic.ui.NavigationKey
+import org.fdroid.basic.ui.getPreviewVersion
import org.fdroid.basic.ui.main.BottomBar
import org.fdroid.basic.ui.main.lists.Sort
import org.fdroid.fdroid.ui.theme.FDroidContent
+import java.util.concurrent.TimeUnit.DAYS
@Composable
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
fun MyApps(
- updatableApps: List,
- installedApps: List,
- currentItem: AppDetailsItem?,
- onItemClick: (MinimalApp) -> Unit,
+ myAppsModel: MyAppsModel,
+ currentPackageName: String?,
+ onAppItemClick: (String) -> Unit,
onNav: (NavKey) -> Unit,
- sortBy: Sort,
onSortChanged: (Sort) -> Unit,
+ onRefresh: () -> Unit,
isBigScreen: Boolean,
modifier: Modifier = Modifier,
) {
+ LifecycleStartEffect(myAppsModel) {
+ onRefresh()
+ onStopOrDispose { }
+ }
+ val updatableApps = myAppsModel.appUpdates
+ val installedApps = myAppsModel.installedApps
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
TopAppBar(
title = {
- var sortByMenuExpanded by remember { mutableStateOf(false) }
- Column {
- Text("My apps")
- FilterChip(
- selected = false,
- leadingIcon = {
- val vector = when (sortBy) {
- Sort.NAME -> Icons.Filled.SortByAlpha
- Sort.LATEST -> Icons.Filled.AccessTime
- }
- Icon(
- vector,
- null,
- modifier = Modifier.size(FilterChipDefaults.IconSize)
- )
- },
- trailingIcon = {
- Icon(Icons.Filled.ArrowDropDown, null)
- },
- label = {
- val s = when (sortBy) {
- Sort.NAME -> "Sort by name"
- Sort.LATEST -> "Sort by latest"
- }
- Text(s)
- DropdownMenu(
- expanded = sortByMenuExpanded,
- onDismissRequest = { sortByMenuExpanded = false },
- ) {
- DropdownMenuItem(
- text = { Text("Sort by name") },
- leadingIcon = {
- Icon(Icons.Filled.SortByAlpha, null)
- },
- onClick = {
- onSortChanged(Sort.NAME)
- sortByMenuExpanded = false
- },
- )
- DropdownMenuItem(
- text = { Text("Sort by latest") },
- leadingIcon = {
- Icon(Icons.Filled.AccessTime, null)
- },
- onClick = {
- onSortChanged(Sort.LATEST)
- sortByMenuExpanded = false
- },
- )
- }
- },
- onClick = { sortByMenuExpanded = !sortByMenuExpanded },
- )
- }
+ Text("My apps")
},
actions = {
- if (updatableApps.isNotEmpty()) Button(
- onClick = {},
- modifier = Modifier.padding(end = 16.dp),
+ var sortByMenuExpanded by remember { mutableStateOf(false) }
+ IconButton(onClick = { sortByMenuExpanded = !sortByMenuExpanded }) {
+ Icon(Icons.Filled.MoreVert, null)
+ }
+ DropdownMenu(
+ expanded = sortByMenuExpanded,
+ onDismissRequest = { sortByMenuExpanded = false },
) {
- Text("Update all")
+ DropdownMenuItem(
+ text = { Text("Sort by name") },
+ leadingIcon = {
+ Icon(Icons.Filled.SortByAlpha, null)
+ },
+ trailingIcon = {
+ RadioButton(
+ selected = myAppsModel.sortOrder == Sort.NAME,
+ onClick = null,
+ )
+ },
+ onClick = {
+ onSortChanged(Sort.NAME)
+ sortByMenuExpanded = false
+ },
+ )
+ DropdownMenuItem(
+ text = { Text("Sort by latest") },
+ leadingIcon = {
+ Icon(Icons.Filled.AccessTime, null)
+ },
+ trailingIcon = {
+ RadioButton(
+ selected = myAppsModel.sortOrder == Sort.LATEST,
+ onClick = null,
+ )
+ },
+ onClick = {
+ onSortChanged(Sort.LATEST)
+ sortByMenuExpanded = false
+ },
+ )
}
},
scrollBehavior = scrollBehavior,
)
},
bottomBar = {
- if (!isBigScreen) BottomBar(updatableApps.size, NavigationKey.MyApps, onNav)
+ if (!isBigScreen) BottomBar(updatableApps?.size ?: 0, NavigationKey.MyApps, onNav)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
- LazyColumn(
+ if (updatableApps == null && installedApps == null) Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ LoadingIndicator(Modifier.size(128.dp))
+ } else LazyColumn(
modifier
.padding(paddingValues)
.then(
- if (currentItem == null) Modifier
+ if (currentPackageName == null) Modifier
else Modifier.selectableGroup()
),
) {
- if (updatableApps.isNotEmpty()) item(key = "A", contentType = "header") {
- Text(
- text = stringResource(R.string.updates),
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(16.dp),
- )
+ if (updatableApps == null || updatableApps.isNotEmpty()) {
+ item(key = "A", contentType = "header") {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = stringResource(R.string.updates),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier
+ .padding(16.dp)
+ .weight(1f),
+ )
+ if (updatableApps?.isNotEmpty() == true) Button(
+ onClick = {},
+ modifier = Modifier.padding(end = 16.dp),
+ ) {
+ Text("Update all")
+ }
+ }
+ }
}
- items(updatableApps, key = { it.packageName }, contentType = { "A" }) { app ->
- val isSelected = app.packageName == currentItem?.app?.packageName
- val interactionModifier = if (currentItem == null) {
+ if (updatableApps != null) items(
+ items = updatableApps,
+ key = { it.packageName },
+ contentType = { "A" },
+ ) { app ->
+ val isSelected = app.packageName == currentPackageName
+ val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
- onClick = { onItemClick(app) }
+ onClick = { onAppItemClick(app.packageName) }
)
} else {
Modifier.selectable(
selected = isSelected,
- onClick = { onItemClick(app) }
+ onClick = { onAppItemClick(app.packageName) }
)
}
val modifier = Modifier
@@ -166,23 +181,29 @@ fun MyApps(
.then(interactionModifier)
UpdatableAppRow(app, isSelected, modifier)
}
- if (updatableApps.isNotEmpty()) item(key = "B", contentType = "header") {
- Text(
- text = stringResource(R.string.installed_apps__activity_title),
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(16.dp),
- )
+ if (!updatableApps.isNullOrEmpty() && !installedApps.isNullOrEmpty()) {
+ item(key = "B", contentType = "header") {
+ Text(
+ text = stringResource(R.string.installed_apps__activity_title),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
}
- items(installedApps, key = { it.packageName }, contentType = { "B" }) { app ->
- val isSelected = app.packageName == currentItem?.app?.packageName
- val interactionModifier = if (currentItem == null) {
+ if (installedApps != null) items(
+ items = installedApps,
+ key = { it.packageName },
+ contentType = { "B" },
+ ) { app ->
+ val isSelected = app.packageName == currentPackageName
+ val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
- onClick = { onItemClick(app) }
+ onClick = { onAppItemClick(app.packageName) }
)
} else {
Modifier.selectable(
selected = isSelected,
- onClick = { onItemClick(app) }
+ onClick = { onAppItemClick(app.packageName) }
)
}
val modifier = Modifier
@@ -195,40 +216,74 @@ fun MyApps(
}
@Preview
-@PreviewScreenSizes
@Composable
-fun MyAppsScaffoldPreview() {
+fun MyAppsLoadingPreview() {
FDroidContent {
- val app1 = UpdatableApp(
- packageName = "A",
- name = Names.randomName,
- currentVersionName = "1.0.1",
- updateVersionName = "1.1.0",
- size = 123456789,
- )
- val app2 = UpdatableApp(
- packageName = "B",
- name = Names.randomName,
- currentVersionName = "3.0.1",
- updateVersionName = "3.1.0",
- size = 9876543,
- )
- val installedApp1 =
- InstalledApp("1", Names.randomName, org.fdroid.basic.ui.Icons.randomIcon, "3.0.1")
- val installedApp2 =
- InstalledApp("2", Names.randomName, org.fdroid.basic.ui.Icons.randomIcon, "1.0")
- val installedApp3 =
- InstalledApp("3", Names.randomName, org.fdroid.basic.ui.Icons.randomIcon, "0.1")
- var sortBy by remember { mutableStateOf(Sort.NAME) }
MyApps(
- updatableApps = listOf(app1, app2),
- installedApps = listOf(installedApp1, installedApp2, installedApp3),
- currentItem = null,
- onItemClick = {},
+ myAppsModel = MyAppsModel(
+ appUpdates = null,
+ installedApps = null,
+ sortOrder = Sort.NAME,
+ ),
+ currentPackageName = null,
+ onAppItemClick = {},
onNav = {},
- sortBy = sortBy,
- onSortChanged = { sortBy = it },
+ onSortChanged = { },
isBigScreen = false,
+ onRefresh = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+fun MyAppsPreview() {
+ FDroidContent {
+ val app1 = AppUpdateItem(
+ packageName = "AX",
+ name = "App Update 123",
+ installedVersionName = "1.0.1",
+ update = getPreviewVersion("1.1.0", 123456789),
+ whatsNew = "This is new, all is new, nothing old.",
+ )
+ val app2 = AppUpdateItem(
+ packageName = "BX",
+ name = Names.randomName,
+ installedVersionName = "3.0.1",
+ update = getPreviewVersion("3.1.0", 9876543),
+ whatsNew = null,
+ )
+ val installedApp1 = InstalledAppItem(
+ packageName = "1",
+ name = Names.randomName,
+ installedVersionName = "1",
+ lastUpdated = System.currentTimeMillis() - DAYS.toMillis(1)
+ )
+ val installedApp2 = InstalledAppItem(
+ packageName = "2",
+ name = Names.randomName,
+ installedVersionName = "2",
+ lastUpdated = System.currentTimeMillis() - DAYS.toMillis(2)
+ )
+ val installedApp3 = InstalledAppItem(
+ packageName = "3",
+ name = Names.randomName,
+ installedVersionName = "3",
+ lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3)
+ )
+ val model = MyAppsModel(
+ appUpdates = listOf(app1, app2),
+ installedApps = listOf(installedApp1, installedApp2, installedApp3),
+ sortOrder = Sort.NAME,
+ )
+ MyApps(
+ myAppsModel = model,
+ currentPackageName = null,
+ onAppItemClick = {},
+ onNav = {},
+ onSortChanged = { },
+ isBigScreen = false,
+ onRefresh = {},
)
}
}
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsPresenter.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsPresenter.kt
new file mode 100644
index 000000000..6e9035d0c
--- /dev/null
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsPresenter.kt
@@ -0,0 +1,44 @@
+package org.fdroid.basic.ui.main.apps
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import kotlinx.coroutines.flow.StateFlow
+import org.fdroid.basic.manager.AppUpdateItem
+import org.fdroid.basic.ui.main.lists.Sort
+import java.text.Collator
+import java.util.Locale
+
+@Composable
+fun MyAppsPresenter(
+ appUpdatesFlow: StateFlow?>,
+ installedAppsFlow: StateFlow?>,
+ sortOrderFlow: StateFlow,
+): MyAppsModel {
+ val appUpdates = appUpdatesFlow.collectAsState().value
+ val installedApps = installedAppsFlow.collectAsState().value
+ val sortOrder = sortOrderFlow.collectAsState().value
+ val packageNames = appUpdates?.map { it.packageName } ?: emptyList()
+ val collator = Collator.getInstance(Locale.getDefault())
+ return MyAppsModel(
+ appUpdates = when (sortOrder) {
+ Sort.NAME -> appUpdates?.sortedWith { a1, a2 -> collator.compare(a1.name, a2.name) }
+ Sort.LATEST -> appUpdates?.sortedByDescending { it.update.added }
+ },
+ installedApps = installedApps?.filter {
+ // filter out apps already in updates
+ it.packageName !in packageNames
+ }?.let { apps ->
+ when (sortOrder) {
+ Sort.NAME -> apps.sortedWith { a1, a2 -> collator.compare(a1.name, a2.name) }
+ Sort.LATEST -> apps.sortedByDescending { it.lastUpdated }
+ }
+ },
+ sortOrder = sortOrder,
+ )
+}
+
+data class MyAppsModel(
+ val appUpdates: List? = null,
+ val installedApps: List? = null,
+ val sortOrder: Sort = Sort.NAME,
+)
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsViewModel.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsViewModel.kt
new file mode 100644
index 000000000..3df9ff41e
--- /dev/null
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsViewModel.kt
@@ -0,0 +1,94 @@
+package org.fdroid.basic.ui.main.apps
+
+import android.app.Application
+import androidx.core.os.LocaleListCompat
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.Observer
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.application
+import androidx.lifecycle.viewModelScope
+import app.cash.molecule.AndroidUiDispatcher
+import app.cash.molecule.RecompositionMode.ContextClock
+import app.cash.molecule.launchMolecule
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.fdroid.basic.download.getDownloadRequest
+import org.fdroid.basic.manager.MyAppsManager
+import org.fdroid.basic.repo.RepositoryManager
+import org.fdroid.basic.ui.main.lists.Sort
+import org.fdroid.database.AppListItem
+import org.fdroid.database.FDroidDatabase
+import org.fdroid.download.DownloadRequest
+import javax.inject.Inject
+
+data class InstalledAppItem(
+ val packageName: String,
+ val name: String,
+ val installedVersionName: String,
+ val lastUpdated: Long,
+ val iconDownloadRequest: DownloadRequest? = null,
+)
+
+@HiltViewModel
+class MyAppsViewModel @Inject constructor(
+ app: Application,
+ savedStateHandle: SavedStateHandle,
+ private val db: FDroidDatabase,
+ private val myAppsManager: MyAppsManager,
+ private val repositoryManager: RepositoryManager,
+) : AndroidViewModel(app) {
+
+ val updates = myAppsManager.updates
+ val numUpdates = myAppsManager.numUpdates
+ private val installedApps = MutableStateFlow?>(null)
+ private var installedAppsLiveData =
+ db.getAppDao().getInstalledAppListItems(application.packageManager)
+ private val sortOrder = savedStateHandle.getMutableStateFlow("sort", Sort.NAME)
+ private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
+ private val localeList = LocaleListCompat.getDefault()
+ private val installedAppsObserver = Observer> { list ->
+ installedApps.value = list.map { app ->
+ InstalledAppItem(
+ packageName = app.packageName,
+ name = app.name ?: "Unknown app",
+ installedVersionName = app.installedVersionName ?: "???",
+ lastUpdated = app.lastUpdated,
+ iconDownloadRequest = repositoryManager.getRepository(app.repoId)?.let { repo ->
+ app.getIcon(localeList)?.getDownloadRequest(repo)
+ },
+ )
+ }
+ }
+ val myAppsModel: StateFlow = scope.launchMolecule(mode = ContextClock) {
+ MyAppsPresenter(
+ appUpdatesFlow = updates,
+ installedAppsFlow = installedApps,
+ sortOrderFlow = sortOrder,
+ )
+ }
+
+ init {
+ installedAppsLiveData.observeForever(installedAppsObserver)
+ }
+
+ override fun onCleared() {
+ installedAppsLiveData.removeObserver(installedAppsObserver)
+ }
+
+ fun changeSortOrder(sort: Sort) {
+ sortOrder.value = sort
+ }
+
+ fun refresh() {
+ myAppsManager.loadUpdates()
+
+ // need to get new liveData from the DB, so it re-queries installed packages
+ installedAppsLiveData.removeObserver(installedAppsObserver)
+ installedAppsLiveData =
+ db.getAppDao().getInstalledAppListItems(application.packageManager).apply {
+ observeForever(installedAppsObserver)
+ }
+ }
+}
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableApp.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableApp.kt
deleted file mode 100644
index 0cb7d1b6a..000000000
--- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableApp.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package org.fdroid.basic.ui.main.apps
-
-import android.os.Parcelable
-import kotlinx.parcelize.Parcelize
-
-@Parcelize
-data class UpdatableApp(
- override val packageName: String,
- override val name: String,
- val currentVersionName: String,
- val updateVersionName: String,
- val size: Long,
- override val icon: String? = null,
- val whatsNew: String? = null,
-): MinimalApp, Parcelable
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableAppRow.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableAppRow.kt
index 2e97b109e..f4311d4ad 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableAppRow.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableAppRow.kt
@@ -2,17 +2,16 @@ package org.fdroid.basic.ui.main.apps
import android.text.format.Formatter
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.NewReleases
import androidx.compose.material3.BadgedBox
+import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
@@ -25,17 +24,21 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
+import org.fdroid.basic.R
+import org.fdroid.basic.manager.AppUpdateItem
+import org.fdroid.basic.ui.getPreviewVersion
+import org.fdroid.basic.ui.icons.PackageName
import org.fdroid.fdroid.ui.theme.FDroidContent
@Composable
fun UpdatableAppRow(
- app: UpdatableApp,
+ app: AppUpdateItem,
isSelected: Boolean,
modifier: Modifier = Modifier
) {
@@ -50,21 +53,11 @@ fun UpdatableAppRow(
contentDescription = null, modifier = Modifier.size(24.dp),
)
}) {
- app.icon?.let {
- AsyncImage(
- model = app.icon,
- contentDescription = null,
- modifier = Modifier.size(48.dp),
- )
- } ?: Icon(
- Icons.Filled.Android,
- tint = MaterialTheme.colorScheme.secondary,
+ AsyncImage(
+ model = PackageName(app.packageName, app.iconDownloadRequest),
+ error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
- modifier = Modifier
- .clip(MaterialTheme.shapes.medium)
- .size(48.dp)
- .background(Color.White)
- .padding(8.dp),
+ modifier = Modifier.size(48.dp),
)
}
},
@@ -72,10 +65,10 @@ fun UpdatableAppRow(
Text(app.name)
},
supportingContent = {
- val size = app.size.let {
+ val size = app.update.size?.let {
Formatter.formatFileSize(LocalContext.current, it)
}
- Text("${app.currentVersionName} → ${app.updateVersionName} • $size")
+ Text("${app.installedVersionName} → ${app.update.versionName} • $size")
},
trailingContent = {
if (app.whatsNew != null) IconButton(onClick = { isExpanded = !isExpanded }) {
@@ -96,13 +89,13 @@ fun UpdatableAppRow(
modifier = modifier,
)
AnimatedVisibility(visible = isExpanded, modifier = Modifier.padding(8.dp)) {
- Text(
- text = app.whatsNew ?: "",
- modifier = Modifier
- .background(MaterialTheme.colorScheme.surfaceVariant)
- .fillMaxWidth()
- .padding(8.dp)
- )
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = app.whatsNew ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(8.dp)
+ )
+ }
}
}
}
@@ -110,18 +103,24 @@ fun UpdatableAppRow(
@Preview
@Composable
fun UpdatableAppRowPreview() {
- val app = UpdatableApp(
- packageName = "",
+ val app1 = AppUpdateItem(
+ packageName = "A",
name = "App Update 123",
- currentVersionName = "1.0.1",
- updateVersionName = "1.1.0",
- size = 123456789,
+ installedVersionName = "1.0.1",
+ update = getPreviewVersion("1.1.0", 123456789),
+ whatsNew = "This is new, all is new, nothing old.",
+ )
+ val app2 = AppUpdateItem(
+ packageName = "B",
+ name = "App Update 456",
+ installedVersionName = "1.0.1",
+ update = getPreviewVersion("1.1.0", 123456789),
whatsNew = "This is new, all is new, nothing old.",
)
FDroidContent {
Column {
- UpdatableAppRow(app, false)
- UpdatableAppRow(app, true)
+ UpdatableAppRow(app1, false)
+ UpdatableAppRow(app2, true)
}
}
}
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetails.kt b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetails.kt
index 9d1593697..dc1fb0e39 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetails.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetails.kt
@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChangeHistory
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.CurrencyBitcoin
@@ -25,23 +24,19 @@ import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Mail
-import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.OndemandVideo
import androidx.compose.material.icons.filled.Person
-import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
import androidx.compose.material3.carousel.rememberCarouselState
@@ -55,7 +50,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
@@ -69,7 +63,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.core.os.LocaleListCompat
import coil3.compose.AsyncImage
+import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.basic.R
import org.fdroid.basic.details.AppDetailsItem
import org.fdroid.basic.details.testApp
@@ -83,13 +79,13 @@ import org.fdroid.fdroid.ui.theme.FDroidContent
ExperimentalSharedTransitionApi::class
)
fun AppDetails(
- appItem: AppDetailsItem?,
+ item: AppDetailsItem?,
onBackNav: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
- if (appItem == null) Box(
+ if (item == null) Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) {
@@ -97,44 +93,9 @@ fun AppDetails(
} else Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
- TopAppBar(
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = Color.Transparent,
- ),
- title = {
- if (topAppBarState.overlappedFraction == 1f) {
- Text(appItem.name)
- }
- },
- navigationIcon = {
- if (onBackNav != null) IconButton(onClick = onBackNav) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = "Localized description",
- )
- }
- },
- actions = {
- IconButton(onClick = { /* do something */ }) {
- Icon(
- imageVector = Icons.Filled.Share,
- contentDescription = "Localized description",
- )
- }
- var expanded by remember { mutableStateOf(false) }
- IconButton(onClick = { expanded = !expanded }) {
- Icon(
- imageVector = Icons.Filled.MoreVert,
- contentDescription = "Localized description",
- )
- }
- AppDetailsMenu(expanded) { expanded = false }
- },
- scrollBehavior = scrollBehavior,
- )
+ AppDetailsTopAppBar(item, topAppBarState, scrollBehavior, onBackNav)
},
) { innerPadding ->
- val item = appItem
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
@@ -356,6 +317,13 @@ fun AppDetails(
url = sourceCode,
)
}
+ item.app.video?.getBestLocale(LocaleListCompat.getDefault())?.let { video ->
+ AppDetailsLink(
+ icon = Icons.Default.OndemandVideo,
+ title = stringResource(R.string.menu_video),
+ url = video,
+ )
+ }
}
}
// Versions
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsHeader.kt b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsHeader.kt
index 08683df6d..40863c5c7 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsHeader.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsHeader.kt
@@ -1,7 +1,6 @@
package org.fdroid.basic.ui.main.details
import android.text.format.Formatter
-import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
-import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
@@ -36,6 +34,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -91,17 +90,10 @@ fun AppDetailsHeader(
horizontalArrangement = spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- item.icon?.let { icon ->
- AsyncImage(
- model = icon,
- contentDescription = "",
- placeholder = rememberVectorPainter(Icons.Default.Image),
- error = rememberVectorPainter(Icons.Default.Error),
- modifier = Modifier.size(64.dp),
- )
- } ?: Image(
- painter = rememberVectorPainter(Icons.Default.Image),
- contentDescription = null,
+ AsyncImage(
+ model = item.icon,
+ contentDescription = "",
+ error = painterResource(R.drawable.ic_repo_app_default),
modifier = Modifier.size(64.dp),
)
Column {
@@ -166,7 +158,7 @@ fun AppDetailsHeader(
val context = LocalContext.current
OutlinedButton(
onClick = {
- context.startActivity(item.launchIntent)
+ context.startActivity(item.actions.launchIntent)
},
modifier = Modifier.weight(1f)
) {
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsMenu.kt b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsMenu.kt
index 1ac0e9483..912a1d5c0 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsMenu.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsMenu.kt
@@ -1,5 +1,6 @@
package org.fdroid.basic.ui.main.details
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Preview
@@ -11,56 +12,106 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import org.fdroid.basic.R
+import org.fdroid.basic.details.AppDetailsItem
+import org.fdroid.basic.details.testApp
+import org.fdroid.fdroid.ui.theme.FDroidContent
@Composable
-fun AppDetailsMenu(expanded: Boolean, onDismiss: () -> Unit) {
+fun AppDetailsMenu(
+ item: AppDetailsItem,
+ expanded: Boolean,
+ onDismiss: () -> Unit,
+) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
) {
- DropdownMenuItem(
- leadingIcon = {
- Icon(Icons.Default.UpdateDisabled, null)
- },
- text = { Text("Ignore all updates") },
- trailingIcon = {
- Checkbox(false, null)
- },
- onClick = { /* Do something... */ },
- )
- DropdownMenuItem(
- leadingIcon = {
- Icon(Icons.Default.UpdateDisabled, null)
- },
- text = { Text("Ignore this updates") },
- trailingIcon = {
- Checkbox(false, null)
- },
- onClick = { /* Do something... */ },
- )
- DropdownMenuItem(
+ if (item.appPrefs != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.Preview, null)
},
- text = { Text("Allow beta updates") },
+ text = { Text(stringResource(R.string.menu_release_channel_beta)) },
trailingIcon = {
- Checkbox(false, null)
+ Checkbox(
+ checked = item.allowsBetaVersions,
+ onCheckedChange = null,
+ enabled = !item.ignoresAllUpdates,
+ )
+ },
+ enabled = !item.ignoresAllUpdates,
+ onClick = {
+ item.actions.allowBetaVersions()
+ onDismiss()
},
- onClick = { /* Do something... */ },
)
- DropdownMenuItem(
+ if (item.actions.ignoreAllUpdates != null) DropdownMenuItem(
+ leadingIcon = {
+ Icon(Icons.Default.UpdateDisabled, null)
+ },
+ text = { Text(stringResource(R.string.menu_ignore_all)) },
+ trailingIcon = {
+ Checkbox(item.ignoresAllUpdates, null)
+ },
+ onClick = {
+ item.actions.ignoreAllUpdates()
+ onDismiss()
+ },
+ )
+ if (item.actions.ignoreThisUpdate != null) DropdownMenuItem(
+ leadingIcon = {
+ Icon(Icons.Default.UpdateDisabled, null)
+ },
+ text = { Text(stringResource(R.string.menu_ignore_this)) },
+ trailingIcon = {
+ Checkbox(
+ checked = item.ignoresCurrentUpdate,
+ onCheckedChange = null,
+ enabled = !item.ignoresAllUpdates,
+ )
+ },
+ enabled = !item.ignoresAllUpdates,
+ onClick = {
+ item.actions.ignoreThisUpdate()
+ onDismiss()
+ },
+ )
+ if (item.actions.shareApk != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.Share, null)
},
- text = { Text("Share APK") },
- onClick = { /* Do something... */ },
+ text = { Text(stringResource(R.string.menu_share_apk)) },
+ onClick = {
+ item.actions.shareApk()
+ onDismiss()
+ },
)
- DropdownMenuItem(
+ if (item.actions.uninstallApp != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.Delete, null)
},
- text = { Text("Uninstall app") },
- onClick = { /* Do something... */ },
+ text = { Text(stringResource(R.string.menu_uninstall)) },
+ onClick = {
+ item.actions.uninstallApp()
+ onDismiss()
+ },
)
}
}
+
+@Preview
+@Composable
+fun AppDetailsMenuPreview() {
+ AppDetailsMenu(testApp, true) {}
+}
+
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+fun AppDetailsMenuAllIgnoredPreview() {
+ val appPrefs = testApp.appPrefs!!.toggleIgnoreAllUpdates()
+ FDroidContent {
+ AppDetailsMenu(testApp.copy(appPrefs = appPrefs), true) {}
+ }
+}
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsTopAppBar.kt b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsTopAppBar.kt
new file mode 100644
index 000000000..b7052a146
--- /dev/null
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsTopAppBar.kt
@@ -0,0 +1,73 @@
+package org.fdroid.basic.ui.main.details
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.TopAppBarState
+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.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import org.fdroid.basic.R
+import org.fdroid.basic.details.AppDetailsItem
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AppDetailsTopAppBar(
+ item: AppDetailsItem,
+ topAppBarState: TopAppBarState,
+ scrollBehavior: TopAppBarScrollBehavior,
+ onBackNav: (() -> Unit)?,
+) {
+ TopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent,
+ ),
+ title = {
+ if (topAppBarState.overlappedFraction == 1f) {
+ Text(item.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ },
+ navigationIcon = {
+ if (onBackNav != null) IconButton(onClick = onBackNav) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ actions = {
+ val context = LocalContext.current
+ item.actions.shareIntent?.let { shareIntent ->
+ IconButton(onClick = { context.startActivity(shareIntent) }) {
+ Icon(
+ imageVector = Icons.Filled.Share,
+ contentDescription = stringResource(R.string.menu_share),
+ )
+ }
+ }
+ var expanded by remember { mutableStateOf(false) }
+ IconButton(onClick = { expanded = !expanded }) {
+ Icon(
+ imageVector = Icons.Filled.MoreVert,
+ contentDescription = "Localized description",
+ )
+ }
+ AppDetailsMenu(item, expanded) { expanded = false }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+}
diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/lists/AppList.kt b/basic/src/main/java/org/fdroid/basic/ui/main/lists/AppList.kt
index a25148b41..14b7d3b75 100644
--- a/basic/src/main/java/org/fdroid/basic/ui/main/lists/AppList.kt
+++ b/basic/src/main/java/org/fdroid/basic/ui/main/lists/AppList.kt
@@ -36,7 +36,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
-import org.fdroid.basic.details.AppDetailsItem
import org.fdroid.basic.ui.main.apps.MinimalApp
sealed class AppList(val title: String) {
@@ -50,7 +49,7 @@ sealed class AppList(val title: String) {
fun AppList(
appList: AppList,
filterInfo: FilterInfo,
- currentItem: AppDetailsItem?,
+ currentPackageName: String?,
modifier: Modifier = Modifier,
onBackClicked: () -> Unit,
onItemClick: (MinimalApp) -> Unit,
@@ -100,14 +99,14 @@ fun AppList(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.then(
- if (currentItem == null) Modifier
+ if (currentPackageName == null) Modifier
else Modifier.selectableGroup()
),
) {
val apps = filterInfo.model.apps
items(apps, key = { it.packageName }, contentType = { "A" }) { navItem ->
- val isSelected = currentItem?.app?.packageName == navItem.packageName
- val interactionModifier = if (currentItem == null) {
+ val isSelected = currentPackageName == navItem.packageName
+ val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
onClick = { onItemClick(navItem) }
)