mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-04-18 05:47:22 -04:00
Use real data for 'My apps' and make app details menu functional
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
@@ -31,8 +31,7 @@
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove">
|
||||
</provider>
|
||||
tools:node="remove" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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<DownloadRequest> {
|
||||
val downloadRequestKeyer = object : Keyer<DownloadRequest> {
|
||||
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<PackageName> {
|
||||
override fun key(data: PackageName, options: Options): String = data.packageName
|
||||
}
|
||||
add(packageNameKeyer)
|
||||
add(ApplicationIconFetcher.Factory(this@App.applicationContext, downloadRequestFetcherFactory))
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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<AntiFeature>? = 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<Repository>,
|
||||
dbApp: App,
|
||||
actions: AppDetailsActions,
|
||||
versions: List<AppVersion>?,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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<AppInfo?>(null)
|
||||
|
||||
val appDetails: StateFlow<AppDetailsItem?> = 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(
|
||||
@@ -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<AppInfo?>,
|
||||
): 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)
|
||||
}
|
||||
|
||||
@@ -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<List<UpdatableApp>>(emptyList())
|
||||
class MyAppsManager @Inject constructor(
|
||||
private val dbUpdateChecker: DbUpdateChecker,
|
||||
@IoDispatcher private val coroutineScope: CoroutineScope,
|
||||
private val repositoryManager: RepositoryManager,
|
||||
) {
|
||||
private val _updates = MutableStateFlow<List<AppUpdateItem>?>(null)
|
||||
val updates = _updates.asStateFlow()
|
||||
private val _installed = MutableStateFlow<List<InstalledApp>>(installedApps)
|
||||
val installed = _installed.asStateFlow()
|
||||
val numUpdates = _updates.map { it.size }
|
||||
private val _sortBy = MutableStateFlow<Sort>(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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<String>? = null
|
||||
override val packageManifest: PackageManifest = object : PackageManifest {
|
||||
override val minSdkVersion: Int? = null
|
||||
override val maxSdkVersion: Int? = null
|
||||
override val featureNames: List<String>? = null
|
||||
override val nativecode: List<String>? = null
|
||||
override val targetSdkVersion: Int? = null
|
||||
}
|
||||
override val hasKnownVulnerability: Boolean = false
|
||||
}
|
||||
|
||||
fun Long.asRelativeTimeString(): String {
|
||||
return DateUtils.getRelativeTimeSpanString(
|
||||
|
||||
@@ -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<PackageName> {
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
@@ -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<NavKey>()//(directive = directive)
|
||||
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>(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<MyAppsViewModel>()
|
||||
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<MyAppsViewModel>()
|
||||
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<NavigationKey.AppDetails>(
|
||||
metadata = ListDetailSceneStrategy.detailPane("appdetails")
|
||||
) {
|
||||
val currentItem = viewModel.appDetails.collectAsStateWithLifecycle().value
|
||||
it.packageName // TODO use?
|
||||
val appDetailsViewModel = hiltViewModel<AppDetailsViewModel>()
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<UpdatableApp>,
|
||||
installedApps: List<InstalledApp>,
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<AppUpdateItem>?>,
|
||||
installedAppsFlow: StateFlow<List<InstalledAppItem>?>,
|
||||
sortOrderFlow: StateFlow<Sort>,
|
||||
): 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<AppUpdateItem>? = null,
|
||||
val installedApps: List<InstalledAppItem>? = null,
|
||||
val sortOrder: Sort = Sort.NAME,
|
||||
)
|
||||
@@ -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<List<InstalledAppItem>?>(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<AppListItem>> { 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<MyAppsModel> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user