mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-07-30 09:06:35 -04:00
Merge branch '2.0' into 'master'
Release 2.0-alpha9 Closes #3262 See merge request fdroid/fdroidclient!1662
This commit is contained in:
@@ -103,6 +103,7 @@ app assembleRelease test:
|
||||
paths:
|
||||
- app/build/reports
|
||||
- app/build/outputs/apk
|
||||
- app/build/outputs/mapping
|
||||
- libs/*/build/reports
|
||||
- legacy/build/reports
|
||||
- legacy/build/outputs/apk
|
||||
@@ -434,16 +435,4 @@ deploy_nightly:
|
||||
- rm -rf app/build/outputs/apk legacy/build/outputs/apk
|
||||
- ./gradlew :app:assembleBasicNightly :legacy:assembleFullDebug
|
||||
- mv app/build/outputs/apk/basicNightly/release/app-basic-nightly-release-unsigned.apk app/build/outputs/apk/basicNightly/release/app-debug.apk
|
||||
|
||||
# taken from fdroiddata/.gitlab-ci.yml as a tmp workaround until this is released:
|
||||
# https://gitlab.com/fdroid/fdroidserver/-/merge_requests/1666
|
||||
- rm -rf $fdroidserver
|
||||
- mkdir $fdroidserver
|
||||
- git ls-remote https://gitlab.com/fdroid/fdroidserver.git master
|
||||
- curl --silent https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz
|
||||
| tar -xz --directory=$fdroidserver --strip-components=1
|
||||
- export PATH="$fdroidserver:$PATH"
|
||||
- export PYTHONPATH="$fdroidserver:$fdroidserver/examples"
|
||||
- export PYTHONUNBUFFERED=true
|
||||
|
||||
- fdroid nightly -v
|
||||
|
||||
@@ -18,8 +18,8 @@ android {
|
||||
applicationId = "org.fdroid"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 2000008
|
||||
versionName = "2.0-alpha8"
|
||||
versionCode = 2000009
|
||||
versionName = "2.0-alpha9"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
@@ -1,4 +1,5 @@
|
||||
-dontobfuscate
|
||||
-dontoptimize
|
||||
-keepattributes SourceFile,LineNumberTable,Exceptions
|
||||
|
||||
# Anything less causes issues like not finding primary constructor in ReflectionDiffer
|
||||
|
||||
@@ -9,7 +9,6 @@ import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@@ -82,7 +81,6 @@ class DnsCacheTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("failing") // TODO
|
||||
fun preloadCacheTest() {
|
||||
// test setup
|
||||
settings.useDnsCache = true
|
||||
|
||||
@@ -30,6 +30,7 @@ class MyAppsScreenshotTest(localeName: String) : LocalizedScreenshotTest(localeN
|
||||
appUpdates = getUpdates(localeList),
|
||||
installingApps = emptyList(),
|
||||
installedApps = getInstalledApps(localeList),
|
||||
showUpdatesHint = false,
|
||||
showAppIssueHint = false,
|
||||
networkState = NetworkState(isOnline = true, isMetered = false),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.fdroid.ui.lists.AppListItem
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
import org.fdroid.ui.search.GlobalSearch
|
||||
import org.fdroid.ui.search.SearchResults
|
||||
import org.fdroid.ui.utils.getSearchInfo
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
@@ -28,14 +29,10 @@ class SearchScreenshotTest(localeName: String) : LocalizedScreenshotTest(localeN
|
||||
apps = getSearchResultItems(localeList),
|
||||
)
|
||||
GlobalSearch(
|
||||
info = getSearchInfo(results, emptyList()),
|
||||
textFieldState = rememberTextFieldState("work"),
|
||||
searchResults = results,
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
|
||||
<!-- Permission used to externally trigger repository updates -->
|
||||
<permission
|
||||
android:name="${applicationId}.permission.UPDATE_REPOS"
|
||||
android:protectionLevel="signature|privileged" />
|
||||
<uses-permission android:name="${applicationId}.permission.UPDATE_REPOS" />
|
||||
|
||||
<application
|
||||
android:name="org.fdroid.App"
|
||||
android:allowBackup="true"
|
||||
@@ -115,6 +121,17 @@
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:ignore="DiscouragedApi"
|
||||
tools:replace="screenOrientation" />
|
||||
<activity
|
||||
android:name="org.fdroid.ui.repositories.UpdateRepoActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:permission="${applicationId}.permission.UPDATE_REPOS">
|
||||
<intent-filter>
|
||||
<action android:name="org.fdroid.action.UPDATE_REPOS" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="org.fdroid.install.AppInstallService"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.fdroid
|
||||
|
||||
import android.Manifest.permission.POST_NOTIFICATIONS
|
||||
import android.app.ComponentCaller
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
@@ -14,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import mu.KotlinLogging
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.fdroid.ui.Main
|
||||
import org.fdroid.ui.utils.launchSafe
|
||||
@@ -23,12 +26,15 @@ import org.fdroid.ui.utils.launchSafe
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
val requestPermissionLauncher = registerForActivityResult(RequestPermission()) {}
|
||||
|
||||
@Inject lateinit var settingsManager: SettingsManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
log.info { "onCreate($savedInstanceState)" }
|
||||
// observe preventScreenshots setting and react to changes
|
||||
lifecycleScope.launch {
|
||||
// this flow doesn't change when we are paused, so we keep collecting it
|
||||
@@ -44,6 +50,7 @@ class MainActivity : AppCompatActivity() {
|
||||
setContent {
|
||||
Main {
|
||||
// inform OnNewIntentListeners about the initial intent (otherwise would be missed)
|
||||
log.info { "Passing initial intent: $intent" }
|
||||
if (intent != null) {
|
||||
onNewIntent(intent)
|
||||
// set intent to null to avoid re-processing on configuration changes
|
||||
@@ -58,4 +65,17 @@ class MainActivity : AppCompatActivity() {
|
||||
requestPermissionLauncher.launchSafe(POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
|
||||
log.info {
|
||||
val callerInfo = if (SDK_INT >= 35) caller.`package` else caller.toString()
|
||||
"onNewIntent: $intent, caller: $callerInfo"
|
||||
}
|
||||
// if the app got killed and restarted via an intent,
|
||||
// this may run before the IntentListener is attached, so it would miss this intent
|
||||
if (SDK_INT >= 35) setIntent(intent, caller) else setIntent(intent)
|
||||
|
||||
// calling super seems to be needed, so the IntentListener gets informed
|
||||
super.onNewIntent(intent, caller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ constructor(private val settingsManager: SettingsManager, private val cache: Dns
|
||||
}
|
||||
|
||||
/**
|
||||
* these methods provide a way to pre-load the cache with values from other sources such as
|
||||
* the mirror info from the index, further reducing the needs to do DNS lookups
|
||||
* these methods provide a way to preload the cache with values from other sources such as the
|
||||
* mirror info from the index, further reducing the needs to do DNS lookups
|
||||
*/
|
||||
fun populateCacheWithStrings(hostname: String, ipv4List: List<String>, ipv6List: List<String>) {
|
||||
val ipv4ConvertedList = stringListToIpList(ipv4List)
|
||||
@@ -35,7 +35,11 @@ constructor(private val settingsManager: SettingsManager, private val cache: Dns
|
||||
populateCacheWithIps(hostname, ipv4ConvertedList, ipv6ConvertedList)
|
||||
}
|
||||
|
||||
fun populateCacheWithIps(hostname: String, ipv4List: List<InetAddress>, ipv6List: List<InetAddress>) {
|
||||
fun populateCacheWithIps(
|
||||
hostname: String,
|
||||
ipv4List: List<InetAddress>,
|
||||
ipv6List: List<InetAddress>,
|
||||
) {
|
||||
// at this time, the DNS cache only supports a single collection because that's what the DNS
|
||||
// lookup method returns. since these values are being inserted manually, it's possible that
|
||||
// the cache might end up with values that wouldn't have been returned by the lookup method.
|
||||
@@ -55,12 +59,12 @@ constructor(private val settingsManager: SettingsManager, private val cache: Dns
|
||||
try {
|
||||
InetAddress.getByName(it)
|
||||
} catch (e: UnknownHostException) {
|
||||
log.warn { "Unexpected format for IP address: $it" }
|
||||
log.error(e) { "Unexpected format for IP address: $it" }
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.warn { "Failed to parse list of IP addresses, returning empty list" }
|
||||
log.error(e) { "Failed to parse list of IP addresses, returning empty list" }
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.fdroid.settings.SettingsConstants.AutoUpdateValues
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.fdroid.utils.IoDispatcher
|
||||
|
||||
@Singleton
|
||||
class NetworkMonitor
|
||||
@@ -21,18 +24,19 @@ class NetworkMonitor
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val settingsManager: SettingsManager,
|
||||
@param:IoDispatcher private val coroutineScope: CoroutineScope,
|
||||
) : ConnectivityManager.NetworkCallback() {
|
||||
|
||||
private val connectivityManager =
|
||||
context.getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
private val neverMetered
|
||||
get() = settingsManager.autoUpdateApps == AutoUpdateValues.Always
|
||||
private val warnWhenMetered
|
||||
get() = settingsManager.warnWhenMeteredFlow.value
|
||||
|
||||
private val _networkState =
|
||||
MutableStateFlow(
|
||||
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let {
|
||||
NetworkState(it, neverMetered)
|
||||
} ?: NetworkState(isOnline = false, isMetered = false)
|
||||
NetworkState(it, warnWhenMetered)
|
||||
} ?: NetworkState(isOnline = false, isMetered = false, warnWhenMetered = warnWhenMetered)
|
||||
)
|
||||
val networkState = _networkState.asStateFlow()
|
||||
|
||||
@@ -43,24 +47,41 @@ constructor(
|
||||
* we are doing instead is simpler and seems to work better.
|
||||
*/
|
||||
connectivityManager.registerDefaultNetworkCallback(this)
|
||||
// observe the setting for whether to warn when on a metered network
|
||||
coroutineScope.launch {
|
||||
// drop first emission as that is the initial value, and we only want to react to changes
|
||||
settingsManager.warnWhenMeteredFlow.drop(1).collect { warnWhenMetered ->
|
||||
if (!warnWhenMetered) {
|
||||
_networkState.update { it.copy(warnWhenMetered = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||
_networkState.update { NetworkState(networkCapabilities, neverMetered) }
|
||||
_networkState.update { NetworkState(networkCapabilities, warnWhenMetered) }
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
_networkState.update { NetworkState(isOnline = false, isMetered = false) }
|
||||
_networkState.update {
|
||||
NetworkState(isOnline = false, isMetered = false, warnWhenMetered = warnWhenMetered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NetworkState(val isOnline: Boolean, val isMetered: Boolean) {
|
||||
data class NetworkState(
|
||||
val isOnline: Boolean,
|
||||
private val isMetered: Boolean,
|
||||
val warnWhenMetered: Boolean = true,
|
||||
) {
|
||||
val showWarningDialog: Boolean = isMetered && warnWhenMetered
|
||||
|
||||
constructor(
|
||||
networkCapabilities: NetworkCapabilities,
|
||||
neverMetered: Boolean,
|
||||
warnWhenMetered: Boolean,
|
||||
) : this(
|
||||
isOnline = networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET),
|
||||
isMetered =
|
||||
if (neverMetered) false else !networkCapabilities.hasCapability(NET_CAPABILITY_NOT_METERED),
|
||||
isMetered = !networkCapabilities.hasCapability(NET_CAPABILITY_NOT_METERED),
|
||||
warnWhenMetered = warnWhenMetered,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.core.content.ContextCompat.registerReceiver
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
@@ -122,7 +123,7 @@ constructor(
|
||||
app: AppMetadata,
|
||||
icon: Bitmap?,
|
||||
canRequestUserConfirmationNow: Boolean,
|
||||
): PreApprovalResult = suspendCancellableCoroutine { cont ->
|
||||
): PreApprovalResult = safeSuspendCoroutine { cont ->
|
||||
val params = getSessionParams(app.packageName)
|
||||
val sessionId = installer.createSession(params)
|
||||
log.info { "Opened session $sessionId for ${app.packageName}" }
|
||||
@@ -133,7 +134,7 @@ constructor(
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
cont.resume(PreApprovalResult.Success(sessionId))
|
||||
context.unregisterReceiver(this)
|
||||
unregisterReceiver(this)
|
||||
}
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
@@ -149,13 +150,13 @@ constructor(
|
||||
log.error(e) { "Error sending pre-approval intent: " }
|
||||
val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent)
|
||||
cont.resume(s)
|
||||
context.unregisterReceiver(this)
|
||||
unregisterReceiver(this)
|
||||
}
|
||||
} else {
|
||||
log.info { "Can not ask pre-approval for ${app.packageName}: $intent" }
|
||||
val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent)
|
||||
cont.resume(s)
|
||||
context.unregisterReceiver(this)
|
||||
unregisterReceiver(this)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -166,14 +167,14 @@ constructor(
|
||||
else -> PreApprovalResult.Error(msg)
|
||||
}
|
||||
cont.resume(result)
|
||||
context.unregisterReceiver(this)
|
||||
unregisterReceiver(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED)
|
||||
cont.invokeOnCancellation {
|
||||
log.info { "Pre-approval for ${app.packageName} cancelled." }
|
||||
context.unregisterReceiver(receiver)
|
||||
unregisterReceiver(receiver)
|
||||
}
|
||||
|
||||
installer.openSession(sessionId).use { session ->
|
||||
@@ -197,7 +198,7 @@ constructor(
|
||||
packageName: String,
|
||||
state: InstallStateWithInfo,
|
||||
apkFile: File,
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
): InstallState = safeSuspendCoroutine { cont ->
|
||||
val size = apkFile.length()
|
||||
log.info { "Installing ${apkFile.name} with size $size bytes" }
|
||||
|
||||
@@ -213,12 +214,12 @@ constructor(
|
||||
log.error(e) { "Error when creating session: " }
|
||||
val s = InstallState.Error("${e::class.java.simpleName} ${e.message}", state)
|
||||
cont.resume(s)
|
||||
return@suspendCancellableCoroutine
|
||||
return@safeSuspendCoroutine
|
||||
}
|
||||
// set-up receiver for install result
|
||||
val receiver =
|
||||
receiverFactory.create(sessionId) { status, intent, msg ->
|
||||
context.unregisterReceiver(this)
|
||||
unregisterReceiver(this)
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
val newState =
|
||||
@@ -278,7 +279,7 @@ constructor(
|
||||
registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED)
|
||||
cont.invokeOnCancellation {
|
||||
log.info { "App installation was cancelled, unregistering broadcast receiver..." }
|
||||
context.unregisterReceiver(receiver)
|
||||
unregisterReceiver(receiver)
|
||||
try {
|
||||
installer.abandonSession(sessionId)
|
||||
} catch (e: SecurityException) {
|
||||
@@ -306,11 +307,11 @@ constructor(
|
||||
}
|
||||
|
||||
suspend fun requestUserConfirmation(state: InstallConfirmationState): InstallState =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
safeSuspendCoroutine { cont ->
|
||||
val isPreApproval = state is InstallState.PreApprovalConfirmationNeeded
|
||||
val receiver =
|
||||
receiverFactory.create(state.sessionId) { status, _, msg ->
|
||||
context.unregisterReceiver(this)
|
||||
unregisterReceiver(this)
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
val newState =
|
||||
@@ -346,12 +347,12 @@ constructor(
|
||||
}
|
||||
}
|
||||
registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED)
|
||||
cont.invokeOnCancellation { context.unregisterReceiver(receiver) }
|
||||
cont.invokeOnCancellation { unregisterReceiver(receiver) }
|
||||
try {
|
||||
state.intent.send()
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error sending user confirmation intent: " }
|
||||
context.unregisterReceiver(receiver)
|
||||
unregisterReceiver(receiver)
|
||||
cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}", state))
|
||||
}
|
||||
}
|
||||
@@ -423,4 +424,47 @@ constructor(
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, sessionId, broadcastIntent, flags)
|
||||
return pendingIntent.intentSender
|
||||
}
|
||||
|
||||
private fun unregisterReceiver(receiver: InstallBroadcastReceiver) {
|
||||
try {
|
||||
context.unregisterReceiver(receiver)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Duplicate callbacks can race with unregistration
|
||||
log.debug(e) { "Receiver already unregistered" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to create a suspend function that can be resumed only once. This shouldn't be
|
||||
* needed, but we have seen crash reports where we received [PackageInstaller.STATUS_SUCCESS] and
|
||||
* then [PackageInstaller.STATUS_FAILURE_ABORTED] for the same sessionId.
|
||||
*/
|
||||
private suspend fun <T> safeSuspendCoroutine(block: (SafeContinuation<T>) -> Unit): T =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val safeCont =
|
||||
object : SafeContinuation<T> {
|
||||
private val isCompleted = AtomicBoolean(false)
|
||||
private var resumedValue: T? = null
|
||||
|
||||
override fun resume(value: T) {
|
||||
if (isCompleted.compareAndSet(false, true)) {
|
||||
resumedValue = value
|
||||
continuation.resume(value)
|
||||
} else {
|
||||
log.warn { "Already resumed with $resumedValue, not trying to resume with $value" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun invokeOnCancellation(handler: () -> Unit) {
|
||||
continuation.invokeOnCancellation { handler() }
|
||||
}
|
||||
}
|
||||
block(safeCont)
|
||||
}
|
||||
|
||||
private interface SafeContinuation<T> {
|
||||
fun resume(value: T)
|
||||
|
||||
fun invokeOnCancellation(handler: () -> Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ constructor(
|
||||
}
|
||||
try {
|
||||
if (canStartForegroundService(applicationContext)) setForeground(getForegroundInfo())
|
||||
else log.info { "Not using foreground service" }
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error while running setForeground: " }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import java.text.Normalizer.Form.NFKD
|
||||
object SearchHelper {
|
||||
private val normalizerRegex = "\\p{M}".toRegex()
|
||||
|
||||
fun CharSequence.isSearchable(): Boolean =
|
||||
isNotBlank() && (length >= 3 || any { Character.isIdeographic(it.code) })
|
||||
|
||||
/** Normalizes the string by removing any diacritics that may appear. */
|
||||
fun String.normalize(): String {
|
||||
if (Normalizer.isNormalized(this, NFKD)) return this
|
||||
|
||||
@@ -28,16 +28,13 @@ import org.fdroid.download.PackageName
|
||||
import org.fdroid.download.getImageModel
|
||||
import org.fdroid.index.RepoManager
|
||||
import org.fdroid.install.InstalledAppsCache
|
||||
import org.fdroid.search.SearchHelper.normalize
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
import org.fdroid.ui.lists.AppListItem
|
||||
import org.fdroid.search.SearchHelper.normalize
|
||||
import org.fdroid.ui.search.SearchResults
|
||||
import org.fdroid.utils.IoDispatcher
|
||||
|
||||
/** The minimum amount of characters we start auto-searching for. */
|
||||
const val SEARCH_THRESHOLD = 2
|
||||
|
||||
@Singleton
|
||||
class SearchManager
|
||||
@Inject
|
||||
@@ -55,16 +52,20 @@ constructor(
|
||||
private val collator = Collator.getInstance(Locale.getDefault())
|
||||
private val _searchResults = MutableStateFlow<SearchResults?>(null)
|
||||
private val _savedSearches = MutableStateFlow<List<SavedSearch>?>(null)
|
||||
private val categories =
|
||||
private var searchJob: SearchJob? = null
|
||||
|
||||
val categories =
|
||||
db.getRepositoryDao().getLiveCategories().asFlow().map { categories ->
|
||||
categories
|
||||
.map { category ->
|
||||
CategoryItem(id = category.id, name = category.getName(localeList) ?: "Unknown Category")
|
||||
CategoryItem(
|
||||
id = category.id,
|
||||
name = category.getName(localeList) ?: "Unknown Category",
|
||||
description = category.getDescription(localeList),
|
||||
)
|
||||
}
|
||||
.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) }
|
||||
}
|
||||
private var searchJob: SearchJob? = null
|
||||
|
||||
val searchResults = _searchResults.asStateFlow()
|
||||
val savedSearches = _savedSearches.asStateFlow()
|
||||
|
||||
@@ -116,7 +117,9 @@ constructor(
|
||||
val timedCategories = measureTimedValue {
|
||||
categories.first().filter {
|
||||
// normalization removed diacritics, so searches without them work
|
||||
it.name.normalize().contains(sanitized.normalize(), ignoreCase = true)
|
||||
val normalized = sanitized.normalize()
|
||||
it.name.normalize().contains(normalized, ignoreCase = true) ||
|
||||
(it.description?.normalize()?.contains(normalized, ignoreCase = true) ?: false)
|
||||
}
|
||||
}
|
||||
_searchResults.value = SearchResults(timedApps.value, timedCategories.value)
|
||||
|
||||
@@ -18,14 +18,19 @@ class OnboardingManager
|
||||
constructor(@param:ApplicationContext private val context: Context) {
|
||||
|
||||
private companion object {
|
||||
const val KEY_MY_APPS_UPDATES = "myAppsUpdates"
|
||||
const val KEY_FILTER = "appFilter"
|
||||
const val KEY_REPO_LIST = "repoList"
|
||||
const val KEY_REPO_DETAILS = "repoDetails"
|
||||
const val KEY_APP_ISSUE_HINT = "appIssueHint"
|
||||
const val KEY_ANTI_FEATURES = "antiFeatures"
|
||||
}
|
||||
|
||||
private val prefs = context.getSharedPreferences("onboarding", MODE_PRIVATE)
|
||||
|
||||
private val _showMyAppsUpdatesHint = Onboarding(KEY_MY_APPS_UPDATES, prefs)
|
||||
val showMyAppsUpdatesHint = _showMyAppsUpdatesHint.flow
|
||||
|
||||
private val _showFilterOnboarding = Onboarding(KEY_FILTER, prefs)
|
||||
val showFilterOnboarding = _showFilterOnboarding.flow
|
||||
|
||||
@@ -38,6 +43,13 @@ constructor(@param:ApplicationContext private val context: Context) {
|
||||
private val _showAppIssueHint = Onboarding(KEY_APP_ISSUE_HINT, prefs)
|
||||
val showAppIssueHint = _showAppIssueHint.flow
|
||||
|
||||
private val _showAntiFeaturesOnboarding = Onboarding(KEY_ANTI_FEATURES, prefs)
|
||||
val showAntiFeaturesOnboarding = _showAntiFeaturesOnboarding.flow
|
||||
|
||||
fun onMyAppsUpdateHintSeen() {
|
||||
_showMyAppsUpdatesHint.onSeen(prefs)
|
||||
}
|
||||
|
||||
fun onFilterOnboardingSeen() {
|
||||
_showFilterOnboarding.onSeen(prefs)
|
||||
}
|
||||
@@ -53,6 +65,10 @@ constructor(@param:ApplicationContext private val context: Context) {
|
||||
fun onAppIssueHintSeen() {
|
||||
_showAppIssueHint.onSeen(prefs)
|
||||
}
|
||||
|
||||
fun onAntiFeaturesOnboardingSeen() {
|
||||
_showAntiFeaturesOnboarding.onSeen(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Onboarding(val key: String, private val _flow: MutableStateFlow<Boolean>) {
|
||||
|
||||
@@ -55,6 +55,12 @@ object SettingsConstants {
|
||||
const val PREF_KEY_PREVENT_SCREENSHOTS = "preventScreenshots"
|
||||
const val PREF_DEFAULT_PREVENT_SCREENSHOTS = false
|
||||
|
||||
const val PREF_KEY_WARN_WHEN_METERED = "warnWhenMetered"
|
||||
const val PREF_DEFAULT_WARN_WHEN_METERED = true
|
||||
|
||||
const val PREF_KEY_SHOW_SEARCH_KEYBOARD = "showSearchKeyboard"
|
||||
const val PREF_DEFAULT_SHOW_SEARCH_KEYBOARD = false
|
||||
|
||||
const val PREF_KEY_SHOW_INCOMPATIBLE = "incompatibleVersions"
|
||||
const val PREF_DEFAULT_SHOW_INCOMPATIBLE = true
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_PREVENT_SCREENSHOTS
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_PROXY
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_REPO_UPDATES
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_SHOW_INCOMPATIBLE
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_SHOW_SEARCH_KEYBOARD
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_THEME
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_WARN_WHEN_METERED
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DNS_CACHE
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DNS_CACHE_DEFAULT
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_APP_LIST_SORT_ORDER
|
||||
@@ -49,7 +51,9 @@ import org.fdroid.settings.SettingsConstants.PREF_KEY_PREVENT_SCREENSHOTS
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_PROXY
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_REPO_UPDATES
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_SHOW_INCOMPATIBLE
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_SHOW_SEARCH_KEYBOARD
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_THEME
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_WARN_WHEN_METERED
|
||||
import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE
|
||||
import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE_DEFAULT
|
||||
import org.fdroid.settings.SettingsConstants.getAppListSortOrder
|
||||
@@ -188,6 +192,14 @@ class SettingsManager @Inject constructor(@param:ApplicationContext private val
|
||||
return prefs.edit { putString(PREF_DNS_CACHE, value) }
|
||||
}
|
||||
|
||||
private val _warnWhenMeteredFlow =
|
||||
MutableStateFlow(prefs.getBoolean(PREF_KEY_WARN_WHEN_METERED, PREF_DEFAULT_WARN_WHEN_METERED))
|
||||
val warnWhenMeteredFlow = _warnWhenMeteredFlow.asStateFlow()
|
||||
|
||||
private val _showSearchKeyboardFlow =
|
||||
MutableStateFlow(prefs.getBoolean(PREF_KEY_SHOW_SEARCH_KEYBOARD, PREF_DEFAULT_SHOW_SEARCH_KEYBOARD))
|
||||
val showSearchKeyboardFlow = _showSearchKeyboardFlow.asStateFlow()
|
||||
|
||||
val filterIncompatible: Boolean
|
||||
get() = !prefs.getBoolean(PREF_KEY_SHOW_INCOMPATIBLE, PREF_DEFAULT_SHOW_INCOMPATIBLE)
|
||||
|
||||
@@ -206,6 +218,16 @@ class SettingsManager @Inject constructor(@param:ApplicationContext private val
|
||||
prefs.edit { putString(PREF_KEY_MY_APPS_SORT_ORDER, value.toSettings()) }
|
||||
}
|
||||
|
||||
fun onDontWarnOnMeteredNetwork() {
|
||||
prefs.edit { putBoolean(PREF_KEY_WARN_WHEN_METERED, false) }
|
||||
_warnWhenMeteredFlow.update { false }
|
||||
}
|
||||
|
||||
fun setShowSearchKeyboard(show: Boolean) {
|
||||
prefs.edit { putBoolean(PREF_KEY_SHOW_SEARCH_KEYBOARD, show) }
|
||||
_showSearchKeyboardFlow.update { show }
|
||||
}
|
||||
|
||||
fun saveAppListFilter(sortOrder: AppListSortOrder, filterIncompatible: Boolean) {
|
||||
prefs.edit {
|
||||
putBoolean(PREF_KEY_SHOW_INCOMPATIBLE, !filterIncompatible)
|
||||
|
||||
@@ -8,14 +8,19 @@ import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
|
||||
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import org.fdroid.search.SavedSearch
|
||||
import org.fdroid.ui.apps.myAppsEntry
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
import org.fdroid.ui.details.NoAppSelected
|
||||
import org.fdroid.ui.details.appDetailsEntry
|
||||
import org.fdroid.ui.discover.discoverEntry
|
||||
@@ -32,6 +37,9 @@ import org.fdroid.ui.navigation.toEntries
|
||||
import org.fdroid.ui.navigation.topLevelRoutes
|
||||
import org.fdroid.ui.repositories.repoEntry
|
||||
import org.fdroid.ui.search.GlobalSearch
|
||||
import org.fdroid.ui.search.SearchActions
|
||||
import org.fdroid.ui.search.SearchInfo
|
||||
import org.fdroid.ui.search.SearchResults
|
||||
import org.fdroid.ui.search.SearchViewModel
|
||||
import org.fdroid.ui.settings.Settings
|
||||
import org.fdroid.ui.settings.SettingsViewModel
|
||||
@@ -57,6 +65,7 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
|
||||
calculatePaneScaffoldDirective(windowAdaptiveInfo).copy(horizontalPartitionSpacerSize = 2.dp)
|
||||
}
|
||||
val isBigScreen = directive.maxHorizontalPartitions > 1
|
||||
var showSearchKeyboard by remember { mutableStateOf(false) }
|
||||
|
||||
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
|
||||
discoverEntry(navigator)
|
||||
@@ -69,13 +78,25 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
|
||||
) {
|
||||
val viewModel = hiltViewModel<SearchViewModel>()
|
||||
GlobalSearch(
|
||||
searchResults = viewModel.searchResults.collectAsStateWithLifecycle().value,
|
||||
savedSearches = viewModel.savedSearchesFlow.collectAsStateWithLifecycle().value,
|
||||
onSearch = viewModel::search,
|
||||
onClearSavedSearches = viewModel::onClearSearchHistory,
|
||||
info =
|
||||
object : SearchInfo {
|
||||
override val searchResults: SearchResults? =
|
||||
viewModel.searchResults.collectAsStateWithLifecycle().value
|
||||
override val savedSearches: List<SavedSearch>? =
|
||||
viewModel.savedSearchesFlow.collectAsStateWithLifecycle().value
|
||||
override val categories: List<CategoryItem>? =
|
||||
viewModel.categories.collectAsStateWithLifecycle(null).value
|
||||
override val autoShowKeyboard: Boolean =
|
||||
viewModel.autoShowKeyboard.collectAsStateWithLifecycle().value
|
||||
override val showKeyboard: Boolean = showSearchKeyboard
|
||||
override val actions: SearchActions = viewModel
|
||||
|
||||
override fun onKeyboardShown() {
|
||||
showSearchKeyboard = false
|
||||
}
|
||||
},
|
||||
onNav = { navKey -> navigator.navigate(navKey) },
|
||||
onBack = { navigator.goBack() },
|
||||
onSearchCleared = viewModel::onSearchCleared,
|
||||
)
|
||||
}
|
||||
entry(NavigationKey.Settings) {
|
||||
@@ -119,7 +140,17 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
|
||||
isBigScreen = isBigScreen,
|
||||
showBottomBar = !isBigScreen && navigator.last is MainNavKey,
|
||||
currentNavKey = navigationState.topLevelRoute,
|
||||
onNav = navigator::navigate,
|
||||
onNav = {
|
||||
// don't navigate, if we are already on the selected top level route
|
||||
if (navigationState.topLevelRoute == it) {
|
||||
if (navigationState.topLevelRoute == NavigationKey.Search) {
|
||||
// double tapping the search brings up the keyboard
|
||||
showSearchKeyboard = true
|
||||
}
|
||||
} else {
|
||||
navigator.navigate(it)
|
||||
}
|
||||
},
|
||||
onBack = navigator::goBack,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ManageSearch
|
||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SortByAlpha
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -96,8 +96,8 @@ fun MyApps(
|
||||
title = { Text(stringResource(R.string.menu_apps_my)) },
|
||||
actions = {
|
||||
TopAppBarButton(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.menu_search),
|
||||
imageVector = Icons.AutoMirrored.Default.ManageSearch,
|
||||
contentDescription = stringResource(R.string.search_my_apps_hint),
|
||||
onClick = { searchActive = true },
|
||||
)
|
||||
var sortByMenuExpanded by remember { mutableStateOf(false) }
|
||||
@@ -186,10 +186,8 @@ fun MyAppsLoadingPreview() {
|
||||
val model =
|
||||
MyAppsModel(
|
||||
installingApps = emptyList(),
|
||||
appUpdates = null,
|
||||
installedApps = null,
|
||||
showUpdatesHint = false,
|
||||
showAppIssueHint = false,
|
||||
sortOrder = AppListSortOrder.NAME,
|
||||
networkState = NetworkState(isOnline = false, isMetered = false),
|
||||
)
|
||||
FDroidContent {
|
||||
@@ -223,11 +221,11 @@ fun MyAppsEmptyPreview() {
|
||||
FDroidContent {
|
||||
val model =
|
||||
MyAppsModel(
|
||||
installingApps = emptyList(),
|
||||
appUpdates = emptyList(),
|
||||
installingApps = emptyList(),
|
||||
installedApps = emptyList(),
|
||||
showUpdatesHint = false,
|
||||
showAppIssueHint = false,
|
||||
sortOrder = AppListSortOrder.NAME,
|
||||
networkState = NetworkState(isOnline = false, isMetered = false),
|
||||
)
|
||||
MyApps(
|
||||
|
||||
@@ -15,9 +15,11 @@ data class MyAppsModel(
|
||||
val installingApps: List<InstallingAppItem>,
|
||||
val appsWithIssue: List<AppWithIssueItem>? = null,
|
||||
val installedApps: List<InstalledAppItem>? = null,
|
||||
val showUpdatesHint: Boolean,
|
||||
val showAppIssueHint: Boolean,
|
||||
val sortOrder: AppListSortOrder = AppListSortOrder.NAME,
|
||||
val networkState: NetworkState,
|
||||
val isSearching: Boolean = false,
|
||||
val appUpdatesBytes: Long? = null,
|
||||
)
|
||||
|
||||
@@ -32,7 +34,11 @@ interface MyAppsActions {
|
||||
|
||||
fun ignoreAppIssue(item: AppWithIssueItem)
|
||||
|
||||
fun onUpdatesHintSeen()
|
||||
|
||||
fun onAppIssueHintSeen()
|
||||
|
||||
fun onNotWarnWhenMetered()
|
||||
|
||||
fun exportInstalledApps()
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.fdroid.database.NotAvailable
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.utils.MeteredConnectionDialog
|
||||
import org.fdroid.ui.utils.OfflineBar
|
||||
import org.fdroid.ui.utils.OnboardingCard
|
||||
import org.fdroid.ui.utils.getMyAppsInfo
|
||||
import org.fdroid.ui.utils.myAppsModel
|
||||
|
||||
@@ -74,7 +75,22 @@ fun MyAppsList(
|
||||
modifier.then(if (currentPackageName == null) Modifier else Modifier.selectableGroup()),
|
||||
) {
|
||||
// Updates header with Update all button
|
||||
if (!updatableApps.isNullOrEmpty()) {
|
||||
if (
|
||||
updatableApps != null &&
|
||||
updatableApps.isEmpty() &&
|
||||
myAppsInfo.model.showUpdatesHint &&
|
||||
!myAppsInfo.model.isSearching
|
||||
) {
|
||||
item(key = "A-hint", contentType = "hint") {
|
||||
OnboardingCard(
|
||||
title = stringResource(R.string.apps_updates_hint_title),
|
||||
message = stringResource(R.string.apps_updates_hint_text),
|
||||
buttonText = stringResource(R.string.hide),
|
||||
onGotIt = { myAppsInfo.actions.onUpdatesHintSeen() },
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
} else if (!updatableApps.isNullOrEmpty()) {
|
||||
if (!myAppsInfo.model.networkState.isOnline) {
|
||||
item(key = "OfflineBar", contentType = "offlineBar") { OfflineBar() }
|
||||
}
|
||||
@@ -92,7 +108,7 @@ fun MyAppsList(
|
||||
myAppsInfo.actions.updateAll()
|
||||
showUpdateAllButton = false
|
||||
}
|
||||
if (myAppsInfo.model.networkState.isMetered) {
|
||||
if (myAppsInfo.model.networkState.showWarningDialog) {
|
||||
showMeteredDialog = installLambda
|
||||
} else {
|
||||
installLambda()
|
||||
@@ -211,12 +227,8 @@ fun MyAppsList(
|
||||
NotAvailableDialog(app.packageName) { showNotAvailableDialog = false }
|
||||
}
|
||||
}
|
||||
// Installed apps header (only show when we have non-empty lists above)
|
||||
val aboveNonEmpty =
|
||||
installingApps.isNotEmpty() ||
|
||||
!updatableApps.isNullOrEmpty() ||
|
||||
!appsWithIssue.isNullOrEmpty()
|
||||
if (aboveNonEmpty && !installedApps.isNullOrEmpty()) {
|
||||
// List of installed apps
|
||||
if (!installedApps.isNullOrEmpty()) {
|
||||
item(key = "D", contentType = "header") {
|
||||
Text(
|
||||
text = stringResource(R.string.installed_apps__activity_title),
|
||||
@@ -224,9 +236,6 @@ fun MyAppsList(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// List of installed apps
|
||||
if (installedApps != null)
|
||||
items(items = installedApps, key = { it.packageName }, contentType = { "D" }) { app ->
|
||||
val isSelected = app.packageName == currentPackageName
|
||||
val interactionModifier =
|
||||
@@ -241,12 +250,16 @@ fun MyAppsList(
|
||||
val modifier = Modifier.animateItem().then(interactionModifier)
|
||||
InstalledAppRow(app, isSelected, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
val meteredLambda = showMeteredDialog
|
||||
if (meteredLambda != null)
|
||||
MeteredConnectionDialog(
|
||||
numBytes = myAppsInfo.model.appUpdatesBytes,
|
||||
onConfirm = { meteredLambda() },
|
||||
onConfirm = { notWarnWhenMetered ->
|
||||
if (notWarnWhenMetered) myAppsInfo.actions.onNotWarnWhenMetered()
|
||||
meteredLambda()
|
||||
},
|
||||
onDismiss = { showMeteredDialog = null },
|
||||
)
|
||||
}
|
||||
@@ -264,3 +277,24 @@ private fun MyAppsListPreview() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
@RestrictTo(RestrictTo.Scope.TESTS)
|
||||
private fun Preview() {
|
||||
FDroidContent {
|
||||
MyApps(
|
||||
myAppsInfo =
|
||||
getMyAppsInfo(
|
||||
myAppsModel.copy(
|
||||
appUpdates = emptyList(),
|
||||
installingApps = emptyList(),
|
||||
appsWithIssue = emptyList(),
|
||||
)
|
||||
),
|
||||
currentPackageName = null,
|
||||
onAppItemClick = {},
|
||||
onNav = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ fun MyAppsPresenter(
|
||||
appInstallStatesFlow: StateFlow<Map<String, InstallState>>,
|
||||
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
|
||||
installedAppsFlow: Flow<List<InstalledAppItem>>,
|
||||
showUpdatesHintFlow: StateFlow<Boolean>,
|
||||
showAppIssueHintFlow: StateFlow<Boolean>,
|
||||
searchQueryFlow: StateFlow<String>,
|
||||
sortOrderFlow: StateFlow<AppListSortOrder>,
|
||||
@@ -36,52 +37,48 @@ fun MyAppsPresenter(
|
||||
|
||||
// we want to show apps currently installing/updating even if they have updates available,
|
||||
// so we need to handle those first
|
||||
val installingApps =
|
||||
appInstallStates.mapNotNull { (packageName, state) ->
|
||||
if (state is InstallStateWithInfo) {
|
||||
val keep =
|
||||
searchQuery.isBlank() || state.name.normalize().contains(searchQuery, ignoreCase = true)
|
||||
if (keep) {
|
||||
processedPackageNames.add(packageName)
|
||||
InstallingAppItem(packageName, state)
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val updates =
|
||||
appUpdates?.filter {
|
||||
val installingApps = appInstallStates.mapNotNull { (packageName, state) ->
|
||||
if (state is InstallStateWithInfo) {
|
||||
val keep =
|
||||
if (searchQuery.isBlank()) {
|
||||
it.packageName !in processedPackageNames
|
||||
} else {
|
||||
it.packageName !in processedPackageNames &&
|
||||
it.name.normalize().contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
if (keep) processedPackageNames.add(it.packageName)
|
||||
keep
|
||||
searchQuery.isBlank() || state.name.normalize().contains(searchQuery, ignoreCase = true)
|
||||
if (keep) {
|
||||
processedPackageNames.add(packageName)
|
||||
InstallingAppItem(packageName, state)
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val withIssues =
|
||||
appsWithIssues?.filter {
|
||||
val keep =
|
||||
if (searchQuery.isBlank()) {
|
||||
it.packageName !in processedPackageNames
|
||||
} else {
|
||||
it.packageName !in processedPackageNames &&
|
||||
it.name.normalize().contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
if (keep) processedPackageNames.add(it.packageName)
|
||||
keep
|
||||
}
|
||||
val installed =
|
||||
installedApps?.filter {
|
||||
}
|
||||
val updates = appUpdates?.filter {
|
||||
val keep =
|
||||
if (searchQuery.isBlank()) {
|
||||
it.packageName !in processedPackageNames
|
||||
} else {
|
||||
it.packageName !in processedPackageNames &&
|
||||
it.name.normalize().contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
if (keep) processedPackageNames.add(it.packageName)
|
||||
keep
|
||||
}
|
||||
val withIssues = appsWithIssues?.filter {
|
||||
val keep =
|
||||
if (searchQuery.isBlank()) {
|
||||
it.packageName !in processedPackageNames
|
||||
} else {
|
||||
it.packageName !in processedPackageNames &&
|
||||
it.name.normalize().contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
if (keep) processedPackageNames.add(it.packageName)
|
||||
keep
|
||||
}
|
||||
val installed = installedApps?.filter {
|
||||
if (searchQuery.isBlank()) {
|
||||
it.packageName !in processedPackageNames
|
||||
} else {
|
||||
it.packageName !in processedPackageNames &&
|
||||
it.name.normalize().contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
var updateBytes: Long? = 0L
|
||||
updates?.forEach {
|
||||
val size = it.update.size
|
||||
@@ -102,9 +99,13 @@ fun MyAppsPresenter(
|
||||
appUpdates = updates?.sort(sortOrder),
|
||||
appsWithIssue = withIssues?.sort(sortOrder),
|
||||
installedApps = installed?.sort(sortOrder),
|
||||
showUpdatesHint =
|
||||
showUpdatesHintFlow.collectAsState().value &&
|
||||
installingApps.none { it.installState.showProgress },
|
||||
showAppIssueHint = showAppIssueHintFlow.collectAsState().value,
|
||||
sortOrder = sortOrder,
|
||||
networkState = networkStateFlow.collectAsState().value,
|
||||
isSearching = searchQuery.isNotBlank(),
|
||||
appUpdatesBytes = updateBytes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ constructor(
|
||||
appInstallStatesFlow = appInstallManager.appInstallStates,
|
||||
appsWithIssuesFlow = updatesManager.appsWithIssues,
|
||||
installedAppsFlow = installedAppItems,
|
||||
showUpdatesHintFlow = onboardingManager.showMyAppsUpdatesHint,
|
||||
showAppIssueHintFlow = onboardingManager.showAppIssueHint,
|
||||
searchQueryFlow = searchQuery,
|
||||
sortOrderFlow = sortOrder,
|
||||
@@ -139,8 +140,14 @@ constructor(
|
||||
updatesManager.loadUpdates()
|
||||
}
|
||||
|
||||
override fun onUpdatesHintSeen() = onboardingManager.onMyAppsUpdateHintSeen()
|
||||
|
||||
override fun onAppIssueHintSeen() = onboardingManager.onAppIssueHintSeen()
|
||||
|
||||
override fun onNotWarnWhenMetered() {
|
||||
settingsManager.onDontWarnOnMeteredNetwork()
|
||||
}
|
||||
|
||||
override fun exportInstalledApps() {
|
||||
scope.launch {
|
||||
val stringBuilder =
|
||||
|
||||
@@ -1,19 +1,96 @@
|
||||
package org.fdroid.ui.categories
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Category
|
||||
import androidx.compose.material.icons.filled.Construction
|
||||
import androidx.compose.material.icons.filled.Factory
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.Forum
|
||||
import androidx.compose.material.icons.filled.NetworkCheck
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.SdStorage
|
||||
import androidx.compose.material.icons.filled.VideogameAsset
|
||||
import androidx.compose.material.icons.filled.Wallet
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import org.fdroid.R
|
||||
|
||||
data class CategoryGroup(val id: String, @get:StringRes val name: Int)
|
||||
data class CategoryGroup(
|
||||
val id: String,
|
||||
@get:StringRes val name: Int,
|
||||
@get:StringRes val summary: Int,
|
||||
val imageVector: ImageVector,
|
||||
)
|
||||
|
||||
object CategoryGroups {
|
||||
val productivity = CategoryGroup("productivity", R.string.category_group_productivity)
|
||||
val tools = CategoryGroup("tools", R.string.category_group_tools)
|
||||
val wallets = CategoryGroup("wallets", R.string.category_group_wallets)
|
||||
val media = CategoryGroup("media", R.string.category_group_media)
|
||||
val communication = CategoryGroup("communication", R.string.category_group_communication)
|
||||
val device = CategoryGroup("device", R.string.category_group_device)
|
||||
val network = CategoryGroup("network", R.string.category_group_network)
|
||||
val storage = CategoryGroup("storage", R.string.category_group_storage)
|
||||
val interests = CategoryGroup("interests", R.string.category_group_interests)
|
||||
val misc = CategoryGroup("misc", R.string.category_group_misc)
|
||||
val productivity =
|
||||
CategoryGroup(
|
||||
id = "productivity",
|
||||
name = R.string.category_group_productivity,
|
||||
summary = R.string.category_group_summary_productivity,
|
||||
imageVector = Icons.Default.Factory,
|
||||
)
|
||||
val tools =
|
||||
CategoryGroup(
|
||||
id = "tools",
|
||||
name = R.string.category_group_tools,
|
||||
summary = R.string.category_group_summary_tools,
|
||||
imageVector = Icons.Default.Construction,
|
||||
)
|
||||
val wallets =
|
||||
CategoryGroup(
|
||||
id = "wallets",
|
||||
name = R.string.category_group_wallets,
|
||||
summary = R.string.category_group_summary_wallets,
|
||||
imageVector = Icons.Default.Wallet,
|
||||
)
|
||||
val media =
|
||||
CategoryGroup(
|
||||
id = "media",
|
||||
name = R.string.category_group_media,
|
||||
summary = R.string.category_group_summary_media,
|
||||
imageVector = Icons.Default.VideogameAsset,
|
||||
)
|
||||
val communication =
|
||||
CategoryGroup(
|
||||
id = "communication",
|
||||
name = R.string.category_group_communication,
|
||||
summary = R.string.category_group_summary_communication,
|
||||
imageVector = Icons.Default.Forum,
|
||||
)
|
||||
val device =
|
||||
CategoryGroup(
|
||||
id = "device",
|
||||
name = R.string.category_group_device,
|
||||
summary = R.string.category_group_summary_device,
|
||||
imageVector = Icons.Default.PhoneAndroid,
|
||||
)
|
||||
val network =
|
||||
CategoryGroup(
|
||||
id = "network",
|
||||
name = R.string.category_group_network,
|
||||
summary = R.string.category_group_summary_network,
|
||||
imageVector = Icons.Default.NetworkCheck,
|
||||
)
|
||||
val storage =
|
||||
CategoryGroup(
|
||||
id = "storage",
|
||||
name = R.string.category_group_storage,
|
||||
summary = R.string.category_group_summary_storage,
|
||||
imageVector = Icons.Default.SdStorage,
|
||||
)
|
||||
val interests =
|
||||
CategoryGroup(
|
||||
id = "interests",
|
||||
name = R.string.category_group_interests,
|
||||
summary = R.string.category_group_summary_interests,
|
||||
imageVector = Icons.Default.FavoriteBorder,
|
||||
)
|
||||
val misc =
|
||||
CategoryGroup(
|
||||
id = "misc",
|
||||
name = R.string.category_group_misc,
|
||||
summary = R.string.category_group_summary_misc,
|
||||
imageVector = Icons.Default.Category,
|
||||
)
|
||||
}
|
||||
|
||||
107
app/src/main/kotlin/org/fdroid/ui/categories/CategoryGroupRow.kt
Normal file
107
app/src/main/kotlin/org/fdroid/ui/categories/CategoryGroupRow.kt
Normal file
@@ -0,0 +1,107 @@
|
||||
package org.fdroid.ui.categories
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.Center
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.lists.AppListType
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
import org.fdroid.ui.utils.ExpandIconArrow
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
fun CategoryGroupRow(
|
||||
group: CategoryGroup,
|
||||
categories: List<CategoryItem>,
|
||||
onNav: (NavKey) -> Unit,
|
||||
) {
|
||||
val isPreview = LocalInspectionMode.current
|
||||
var expanded by rememberSaveable { mutableStateOf(isPreview) }
|
||||
val onClick = { expanded = !expanded }
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Box(
|
||||
contentAlignment = Center,
|
||||
modifier =
|
||||
Modifier.size(40.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = group.imageVector,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.semantics { hideFromAccessibility() },
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = stringResource(group.summary), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
trailingContent = { IconButton(onClick = onClick) { ExpandIconArrow(expanded) } },
|
||||
onClick = onClick,
|
||||
) {
|
||||
Text(text = stringResource(group.name), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
ChipFlowRow(modifier = Modifier.padding(8.dp)) {
|
||||
categories.forEach { categoryItem ->
|
||||
CategoryChip(
|
||||
categoryItem = categoryItem,
|
||||
onClick = {
|
||||
val type = AppListType.Category(categoryItem.name, categoryItem.id)
|
||||
val navKey = NavigationKey.AppList(type)
|
||||
onNav(navKey)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewLightDark
|
||||
private fun Preview() {
|
||||
FDroidContent {
|
||||
val categories =
|
||||
listOf(
|
||||
CategoryItem("App Store & Updater", "App Store & Updater"),
|
||||
CategoryItem("Browser", "Browser"),
|
||||
CategoryItem("Calendar & Agenda", "Calendar & Agenda"),
|
||||
)
|
||||
Column { CategoryGroupRow(CategoryGroups.productivity, categories) {} }
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.AppBlocking
|
||||
import androidx.compose.material.icons.filled.Apps
|
||||
import androidx.compose.material.icons.filled.BatteryChargingFull
|
||||
import androidx.compose.material.icons.filled.Bookmarks
|
||||
import androidx.compose.material.icons.filled.BrowserUpdated
|
||||
import androidx.compose.material.icons.filled.Brush
|
||||
import androidx.compose.material.icons.filled.Calculate
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
@@ -74,7 +75,7 @@ import androidx.compose.material.icons.filled.Wallpaper
|
||||
import androidx.compose.material.icons.filled.WbSunny
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
data class CategoryItem(val id: String, val name: String) {
|
||||
data class CategoryItem(val id: String, val name: String, val description: String? = null) {
|
||||
val imageVector: ImageVector
|
||||
get() =
|
||||
when (id) {
|
||||
@@ -130,6 +131,7 @@ data class CategoryItem(val id: String, val name: String) {
|
||||
"Reading" -> AutoMirrored.Default.MenuBook
|
||||
"Recipe Manager" -> Icons.Default.RestaurantMenu
|
||||
"Religion" -> Icons.Default.Church
|
||||
"Remote Access" -> Icons.Default.BrowserUpdated
|
||||
"Remote Controller" -> Icons.Default.SettingsRemote
|
||||
"Science & Education" -> Icons.Default.Science
|
||||
"Security" -> Icons.Default.Security
|
||||
@@ -208,6 +210,7 @@ data class CategoryItem(val id: String, val name: String) {
|
||||
"Reading" -> CategoryGroups.media
|
||||
"Recipe Manager" -> CategoryGroups.interests
|
||||
"Religion" -> CategoryGroups.interests
|
||||
"Remote Access" -> CategoryGroups.network
|
||||
"Remote Controller" -> CategoryGroups.tools
|
||||
"Science & Education" -> CategoryGroups.interests
|
||||
"Security" -> CategoryGroups.device
|
||||
|
||||
@@ -4,33 +4,37 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.fdroid.R
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.lists.AppListType
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
fun CategoryList(
|
||||
categoryMap: Map<CategoryGroup, List<CategoryItem>>?,
|
||||
onNav: (NavKey) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(!categoryMap.isNullOrEmpty()) {
|
||||
Column(modifier = modifier.padding(top = 20.dp)) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.main_menu__categories),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp, start = 16.dp, end = 16.dp),
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
// we'll sort the groups here, because before we didn't have the context to get names
|
||||
val res = LocalResources.current
|
||||
@@ -39,22 +43,12 @@ fun CategoryList(
|
||||
val comparator = compareBy<CategoryGroup> { res.getString(it.name) }
|
||||
categoryMap?.toSortedMap(comparator)
|
||||
}
|
||||
sortedMap?.forEach { (group, categories) ->
|
||||
Text(
|
||||
text = stringResource(group.name),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp, 2.dp),
|
||||
)
|
||||
ChipFlowRow(modifier = Modifier.padding(start = 16.dp, bottom = 12.dp)) {
|
||||
categories.forEach { category ->
|
||||
CategoryChip(
|
||||
categoryItem = category,
|
||||
onClick = {
|
||||
val type = AppListType.Category(category.name, category.id)
|
||||
val navKey = NavigationKey.AppList(type)
|
||||
onNav(navKey)
|
||||
},
|
||||
)
|
||||
OutlinedCard(modifier = Modifier.padding(16.dp)) {
|
||||
// FIXME: A LazyColumn would be better, but we can't use this inside a scrollable column
|
||||
Column {
|
||||
sortedMap?.forEach { (group, categories) ->
|
||||
CategoryGroupRow(group, categories, onNav)
|
||||
HorizontalDivider(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,8 +56,8 @@ fun CategoryList(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
@PreviewLightDark
|
||||
fun CategoryListPreview() {
|
||||
FDroidContent {
|
||||
val categories =
|
||||
|
||||
@@ -37,7 +37,7 @@ fun ChipFlowRowFewItemsPreview() {
|
||||
CategoryItem("doesn't exist", "Oops"),
|
||||
)
|
||||
|
||||
FDroidContent { ChipFlowRow { categories.map { category -> CategoryChip(category, {}) } } }
|
||||
FDroidContent { ChipFlowRow { categories.forEach { category -> CategoryChip(category, {}) } } }
|
||||
}
|
||||
|
||||
@Preview
|
||||
@@ -61,5 +61,5 @@ fun ChipFlowRowManyItemsPreview() {
|
||||
CategoryItem("Science & Education", "Science & Education"),
|
||||
)
|
||||
|
||||
FDroidContent { ChipFlowRow { categories.map { category -> CategoryChip(category, {}) } } }
|
||||
FDroidContent { ChipFlowRow { categories.forEach { category -> CategoryChip(category, {}) } } }
|
||||
}
|
||||
|
||||
@@ -15,13 +15,23 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter.Companion.tint
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.onVisibilityChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.viktormykhailiv.compose.hints.HintAnchorState
|
||||
import com.viktormykhailiv.compose.hints.hintAnchor
|
||||
import com.viktormykhailiv.compose.hints.rememberHint
|
||||
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
|
||||
import org.fdroid.R
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.utils.AsyncShimmerImage
|
||||
@@ -30,11 +40,38 @@ import org.fdroid.ui.utils.testApp
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
fun AntiFeatures(antiFeatures: List<AntiFeature>) {
|
||||
fun AntiFeatures(
|
||||
antiFeatures: List<AntiFeature>,
|
||||
hintAnchor: HintAnchorState,
|
||||
showOnboarding: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onShowOnboarding: () -> Unit,
|
||||
) {
|
||||
var placed by remember { mutableStateOf(false) }
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
ElevatedCard(
|
||||
colors =
|
||||
CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.inverseSurface),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.hintAnchor(state = hintAnchor, shape = CardDefaults.elevatedShape)
|
||||
.onPlaced {
|
||||
if (visible && showOnboarding) {
|
||||
onShowOnboarding()
|
||||
} else {
|
||||
placed = true
|
||||
}
|
||||
}
|
||||
.onVisibilityChanged(minDurationMs = 1000) { isVisible ->
|
||||
if (isVisible && placed && showOnboarding) {
|
||||
onShowOnboarding()
|
||||
} else if (isVisible) {
|
||||
visible = true
|
||||
}
|
||||
}
|
||||
.padding(8.dp),
|
||||
) {
|
||||
ExpandableSection(
|
||||
icon = rememberVectorPainter(Icons.Default.WarningAmber),
|
||||
@@ -81,5 +118,7 @@ fun AntiFeatures(antiFeatures: List<AntiFeature>) {
|
||||
@Preview
|
||||
@Composable
|
||||
fun AntiFeaturesPreview() {
|
||||
FDroidContent { AntiFeatures(testApp.antiFeatures!!) }
|
||||
val hint = rememberHint {}
|
||||
val hintAnchor = rememberHintAnchorState(hint)
|
||||
FDroidContent { AntiFeatures(testApp.antiFeatures!!, hintAnchor, showOnboarding = false) {} }
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -66,6 +67,11 @@ import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.viktormykhailiv.compose.hints.HintProperties
|
||||
import com.viktormykhailiv.compose.hints.rememberHint
|
||||
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
|
||||
import com.viktormykhailiv.compose.hints.rememberHintController
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fdroid.LocaleChooser.getBestLocale
|
||||
import org.fdroid.R
|
||||
import org.fdroid.install.InstallState
|
||||
@@ -78,6 +84,8 @@ import org.fdroid.ui.lists.AppListType
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
import org.fdroid.ui.utils.BigLoadingIndicator
|
||||
import org.fdroid.ui.utils.ExpandableSection
|
||||
import org.fdroid.ui.utils.OnboardingPopupCard
|
||||
import org.fdroid.ui.utils.getHintOverlayColor
|
||||
import org.fdroid.ui.utils.testApp
|
||||
|
||||
@Composable
|
||||
@@ -107,6 +115,22 @@ fun AppDetails(
|
||||
showInstallError = true
|
||||
}
|
||||
}
|
||||
// onboarding hint plumbing
|
||||
val hintController = rememberHintController(overlay = getHintOverlayColor())
|
||||
val hint =
|
||||
rememberHint(HintProperties(dismissOnClickOutside = false)) {
|
||||
OnboardingPopupCard(
|
||||
title = stringResource(R.string.app_details_anti_features_title),
|
||||
message = stringResource(R.string.app_details_anti_features_text),
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
onGotIt = {
|
||||
item.actions.onAntiFeaturesOnboardingSeen()
|
||||
hintController.dismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
val hintAnchor = rememberHintAnchorState(hint)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
Column(
|
||||
@@ -217,11 +241,17 @@ fun AppDetails(
|
||||
}
|
||||
// Anti-features
|
||||
if (!item.antiFeatures.isNullOrEmpty()) {
|
||||
AntiFeatures(item.antiFeatures)
|
||||
AntiFeatures(
|
||||
antiFeatures = item.antiFeatures,
|
||||
hintAnchor = hintAnchor,
|
||||
showOnboarding = item.showAntiFeaturesOnboarding,
|
||||
) {
|
||||
coroutineScope.launch { hintController.show(hintAnchor) }
|
||||
}
|
||||
}
|
||||
// Screenshots
|
||||
if (item.phoneScreenshots.isNotEmpty()) {
|
||||
Screenshots(item.networkState.isMetered, item.phoneScreenshots)
|
||||
Screenshots(item.networkState.showWarningDialog, item.phoneScreenshots)
|
||||
}
|
||||
// Donate card
|
||||
if (item.showDonate)
|
||||
|
||||
@@ -223,6 +223,7 @@ fun AppDetailsHeader(
|
||||
Text(
|
||||
text = stringResource(strRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -236,6 +237,7 @@ fun AppDetailsHeader(
|
||||
Text(
|
||||
text = stringResource(R.string.status_downloading, speedStr),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
@@ -243,6 +245,7 @@ fun AppDetailsHeader(
|
||||
Text(
|
||||
text = stringResource(R.string.status_downloading_remaining, remainingStr),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -304,7 +307,7 @@ fun AppDetailsHeader(
|
||||
// button is for either installing or updating
|
||||
Button(
|
||||
onClick = {
|
||||
if (item.networkState.isMetered) {
|
||||
if (item.networkState.showWarningDialog) {
|
||||
showMeteredDialog = true
|
||||
} else {
|
||||
require(item.suggestedVersion != null) { "suggestedVersion was null" }
|
||||
@@ -325,7 +328,8 @@ fun AppDetailsHeader(
|
||||
if (showMeteredDialog)
|
||||
MeteredConnectionDialog(
|
||||
numBytes = version?.size,
|
||||
onConfirm = {
|
||||
onConfirm = { notWarnWhenMetered ->
|
||||
if (notWarnWhenMetered) item.actions.onNotWarnWhenMetered()
|
||||
require(item.suggestedVersion != null) { "suggestedVersion was null" }
|
||||
item.actions.installAction(item.app, item.suggestedVersion, item.icon)
|
||||
},
|
||||
|
||||
@@ -21,8 +21,8 @@ import org.fdroid.index.RELEASE_CHANNEL_BETA
|
||||
import org.fdroid.index.v2.PackageVersion
|
||||
import org.fdroid.install.InstallState
|
||||
import org.fdroid.install.SessionInstallManager
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
import org.fdroid.search.SearchHelper.removeZeroWhiteSpace
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
|
||||
data class AppDetailsItem(
|
||||
val app: AppMetadata,
|
||||
@@ -62,6 +62,7 @@ data class AppDetailsItem(
|
||||
val appPrefs: AppPrefs? = null,
|
||||
val whatsNew: String? = null,
|
||||
val antiFeatures: List<AntiFeature>? = null,
|
||||
val showAntiFeaturesOnboarding: Boolean = false,
|
||||
val issue: AppIssue? = null,
|
||||
val authorHasMoreThanOneApp: Boolean = false,
|
||||
val proxy: ProxyConfig? = null,
|
||||
@@ -82,6 +83,7 @@ data class AppDetailsItem(
|
||||
suggestedVersion: AppVersion?,
|
||||
possibleUpdate: AppVersion?,
|
||||
appPrefs: AppPrefs?,
|
||||
showAntiFeaturesOnboarding: Boolean,
|
||||
issue: AppIssue?,
|
||||
authorHasMoreThanOneApp: Boolean,
|
||||
localeList: LocaleListCompat,
|
||||
@@ -131,6 +133,7 @@ data class AppDetailsItem(
|
||||
localeList = localeList,
|
||||
proxy = proxy,
|
||||
),
|
||||
showAntiFeaturesOnboarding = showAntiFeaturesOnboarding,
|
||||
issue = issue,
|
||||
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
|
||||
proxy = proxy,
|
||||
@@ -239,7 +242,9 @@ data class AppDetailsActions(
|
||||
val onUninstallResult: (ActivityResult) -> Unit,
|
||||
val onRepoChanged: (Long) -> Unit,
|
||||
val onPreferredRepoChanged: (Long) -> Unit,
|
||||
val onNotWarnWhenMetered: () -> Unit,
|
||||
val allowBetaVersions: () -> Unit,
|
||||
val onAntiFeaturesOnboardingSeen: () -> Unit,
|
||||
val ignoreAllUpdates: (() -> Unit)? = null,
|
||||
val ignoreThisUpdate: (() -> Unit)? = null,
|
||||
val shareApk: Intent? = null,
|
||||
|
||||
@@ -3,11 +3,10 @@ package org.fdroid.ui.details
|
||||
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.activity.result.ActivityResult
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.content.pm.PackageInfoCompat.getLongVersionCode
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -25,9 +24,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mu.KotlinLogging
|
||||
import org.fdroid.UpdateChecker
|
||||
import org.fdroid.database.AppMetadata
|
||||
@@ -40,7 +40,9 @@ import org.fdroid.index.RepoManager
|
||||
import org.fdroid.index.v2.PackageVersion
|
||||
import org.fdroid.install.AppInstallManager
|
||||
import org.fdroid.install.InstallState
|
||||
import org.fdroid.install.InstalledAppsCache
|
||||
import org.fdroid.repo.RepoPreLoader
|
||||
import org.fdroid.settings.OnboardingManager
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.fdroid.updates.UpdatesManager
|
||||
import org.fdroid.utils.IoDispatcher
|
||||
@@ -60,7 +62,9 @@ constructor(
|
||||
private val updatesManager: UpdatesManager,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val onboardingManager: OnboardingManager,
|
||||
private val appInstallManager: AppInstallManager,
|
||||
private val installedAppsCache: InstalledAppsCache,
|
||||
) : AndroidViewModel(app) {
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val packageInfoFlow = MutableStateFlow<AppInfo?>(null)
|
||||
@@ -82,6 +86,7 @@ constructor(
|
||||
viewModel = this,
|
||||
packageInfoFlow = packageInfoFlow,
|
||||
currentRepoIdFlow = currentRepoIdFlow,
|
||||
showAntiFeaturesOnboardingFlow = onboardingManager.showAntiFeaturesOnboarding,
|
||||
appsWithIssuesFlow = updatesManager.appsWithIssues,
|
||||
networkStateFlow = networkMonitor.networkState,
|
||||
)
|
||||
@@ -89,18 +94,20 @@ constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch(dispatcher) { loadPackageInfoFlow() }
|
||||
viewModelScope.launch(dispatcher) {
|
||||
// Collect the cached packageInfo for this app. This way we have a central place
|
||||
// to react to state changes for installs, updates and uninstalls
|
||||
installedAppsCache.installedApps
|
||||
.map { cacheMap -> cacheMap[packageName]?.let { getLongVersionCode(it) } }
|
||||
.distinctUntilChanged()
|
||||
.collect { loadPackageInfoFlow() }
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun loadPackageInfoFlow() {
|
||||
val packageManager = app.packageManager
|
||||
val packageInfo =
|
||||
try {
|
||||
@Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, GET_SIGNATURES)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
val packageInfo = installedAppsCache.installedApps.value[packageName]
|
||||
packageInfoFlow.value =
|
||||
if (packageInfo == null) {
|
||||
AppInfo(packageName)
|
||||
@@ -123,15 +130,15 @@ constructor(
|
||||
packageName = packageName,
|
||||
appMetadata = appMetadata,
|
||||
version = version,
|
||||
currentVersionName = packageInfoFlow.value?.packageInfo?.versionName, // TODO
|
||||
currentVersionName = packageInfoFlow.value?.packageInfo?.versionName,
|
||||
repo = repoManager.getRepository(appMetadata.repoId) ?: return@launch,
|
||||
iconModel = iconModel,
|
||||
canAskPreApprovalNow = true,
|
||||
)
|
||||
if (result is InstallState.Installed) {
|
||||
// to reload packageInfoFlow with fresh packageInfo
|
||||
loadPackageInfoFlow()
|
||||
}
|
||||
log.info { "Install result was: $result" }
|
||||
// We are not doing anything with that result,
|
||||
// because an installation could have been triggered via 'Update all' in My Apps as well.
|
||||
// So updating states need to happen elsewhere.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,11 +146,7 @@ constructor(
|
||||
fun requestUserConfirmation(installState: InstallState.UserConfirmationNeeded) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
val result = appInstallManager.requestUserConfirmation(packageName, installState)
|
||||
if (result is InstallState.Installed)
|
||||
withContext(Dispatchers.Main) {
|
||||
// to reload packageInfoFlow with fresh packageInfo
|
||||
loadPackageInfoFlow()
|
||||
}
|
||||
log.info { "requestUserConfirmation result was: $result" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,10 +167,7 @@ constructor(
|
||||
fun onUninstallResult(activityResult: ActivityResult) {
|
||||
val name = appDetails.value?.name
|
||||
val result = appInstallManager.onUninstallResult(packageName, name, activityResult)
|
||||
if (result is InstallState.Uninstalled) {
|
||||
// to reload packageInfoFlow with fresh packageInfo
|
||||
viewModelScope.launch(dispatcher) { loadPackageInfoFlow() }
|
||||
}
|
||||
log.info { "Uninstall result was: $result" }
|
||||
}
|
||||
|
||||
@UiThread
|
||||
@@ -183,6 +183,11 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun onNotWarnWhenMetered() {
|
||||
settingsManager.onDontWarnOnMeteredNetwork()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
log.info { "App details screen left: $packageName" }
|
||||
appInstallManager.cleanUp(packageName)
|
||||
@@ -230,6 +235,11 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun onAntiFeaturesOnboardingSeen() {
|
||||
onboardingManager.onAntiFeaturesOnboardingSeen()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(packageName: String): AppDetailsViewModel
|
||||
|
||||
@@ -47,6 +47,7 @@ fun DetailsPresenter(
|
||||
viewModel: AppDetailsViewModel,
|
||||
packageInfoFlow: StateFlow<AppInfo?>,
|
||||
currentRepoIdFlow: StateFlow<Long?>,
|
||||
showAntiFeaturesOnboardingFlow: StateFlow<Boolean>,
|
||||
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
|
||||
networkStateFlow: StateFlow<NetworkState>,
|
||||
): AppDetailsItem? {
|
||||
@@ -205,7 +206,9 @@ fun DetailsPresenter(
|
||||
onUninstallResult = viewModel::onUninstallResult,
|
||||
onRepoChanged = viewModel::onRepoChanged,
|
||||
onPreferredRepoChanged = viewModel::onPreferredRepoChanged,
|
||||
onNotWarnWhenMetered = viewModel::onNotWarnWhenMetered,
|
||||
allowBetaVersions = viewModel::allowBetaUpdates,
|
||||
onAntiFeaturesOnboardingSeen = viewModel::onAntiFeaturesOnboardingSeen,
|
||||
ignoreAllUpdates =
|
||||
if (installedVersionCode == null) {
|
||||
null
|
||||
@@ -265,6 +268,7 @@ fun DetailsPresenter(
|
||||
suggestedVersion = suggestedVersion,
|
||||
possibleUpdate = possibleUpdate,
|
||||
appPrefs = appPrefs,
|
||||
showAntiFeaturesOnboarding = showAntiFeaturesOnboardingFlow.collectAsState().value,
|
||||
issue = issue,
|
||||
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
|
||||
localeList = locales,
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -56,10 +57,11 @@ fun Versions(item: AppDetailsItem, scrollUp: suspend () -> Unit) {
|
||||
item.versions?.forEach { versionItem ->
|
||||
Version(
|
||||
item = versionItem,
|
||||
isMetered = item.networkState.isMetered,
|
||||
isMetered = item.networkState.showWarningDialog,
|
||||
installAction = { version: AppVersion ->
|
||||
item.actions.installAction(item.app, version, item.icon)
|
||||
},
|
||||
onNotWarnWhenMetered = item.actions.onNotWarnWhenMetered,
|
||||
scrollUp = scrollUp,
|
||||
)
|
||||
}
|
||||
@@ -72,6 +74,7 @@ fun Version(
|
||||
item: VersionItem,
|
||||
isMetered: Boolean,
|
||||
installAction: (AppVersion) -> Unit,
|
||||
onNotWarnWhenMetered: () -> Unit,
|
||||
scrollUp: suspend () -> Unit,
|
||||
) {
|
||||
val isPreview = LocalInspectionMode.current
|
||||
@@ -83,7 +86,7 @@ fun Version(
|
||||
modifier = Modifier.padding(bottom = 8.dp).clickable { expanded = !expanded },
|
||||
) {
|
||||
ExpandIconChevron(expanded)
|
||||
Row {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.version.versionName,
|
||||
@@ -103,14 +106,14 @@ fun Version(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
) {
|
||||
Text(text = stringResource(R.string.app_installed), modifier = Modifier.padding(2.dp))
|
||||
Text(text = stringResource(R.string.app_installed), modifier = Modifier.padding(4.dp))
|
||||
}
|
||||
if (item.isSuggested)
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
) {
|
||||
Text(text = stringResource(R.string.app_suggested), modifier = Modifier.padding(2.dp))
|
||||
Text(text = stringResource(R.string.app_suggested), modifier = Modifier.padding(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,84 +123,80 @@ fun Version(
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var showMeteredDialog by remember { mutableStateOf(false) }
|
||||
Row(horizontalArrangement = spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (!item.isCompatible || !item.isSignerCompatible)
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(start = 40.dp)) {
|
||||
if (!item.isCompatible || !item.isSignerCompatible)
|
||||
Text(
|
||||
text =
|
||||
if (!item.isCompatible) {
|
||||
stringResource(R.string.app_details_incompatible_version)
|
||||
} else {
|
||||
stringResource(R.string.app_details_incompatible_signer)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
item.version.size?.let { size ->
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.size_colon,
|
||||
Formatter.formatFileSize(LocalContext.current, size),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = stringResource(R.string.version_code_colon, item.version.versionCode.toString()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
val sdkString = buildString {
|
||||
item.version.packageManifest.minSdkVersion?.let { sdk ->
|
||||
append(stringResource(R.string.sdk_min_version, sdk))
|
||||
}
|
||||
item.version.packageManifest.targetSdkVersion?.let { sdk ->
|
||||
if (isNotEmpty()) append(" ")
|
||||
append(stringResource(R.string.sdk_target_version, sdk))
|
||||
}
|
||||
item.version.packageManifest.maxSdkVersion?.let { sdk ->
|
||||
if (isNotEmpty()) append(" ")
|
||||
append(stringResource(R.string.sdk_max_version, sdk))
|
||||
}
|
||||
}
|
||||
if (sdkString.isNotEmpty())
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text =
|
||||
if (!item.isCompatible) {
|
||||
stringResource(R.string.app_details_incompatible_version)
|
||||
} else {
|
||||
stringResource(R.string.app_details_incompatible_signer)
|
||||
},
|
||||
text = stringResource(R.string.sdk_versions_colon, sdkString),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
item.version.size?.let { size ->
|
||||
}
|
||||
item.version.packageManifest.nativecode?.let { nativeCode ->
|
||||
if (nativeCode.isNotEmpty())
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = stringResource(R.string.architectures_colon, nativeCode.joinToString(", ")),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
item.version.signer?.let { signer ->
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.size_colon,
|
||||
Formatter.formatFileSize(LocalContext.current, size),
|
||||
),
|
||||
text = stringResource(R.string.signer_colon, signer.sha256[0].substring(0..15)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text =
|
||||
stringResource(R.string.version_code_colon, item.version.versionCode.toString()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
val sdkString = buildString {
|
||||
item.version.packageManifest.minSdkVersion?.let { sdk ->
|
||||
append(stringResource(R.string.sdk_min_version, sdk))
|
||||
}
|
||||
item.version.packageManifest.targetSdkVersion?.let { sdk ->
|
||||
if (isNotEmpty()) append(" ")
|
||||
append(stringResource(R.string.sdk_target_version, sdk))
|
||||
}
|
||||
item.version.packageManifest.maxSdkVersion?.let { sdk ->
|
||||
if (isNotEmpty()) append(" ")
|
||||
append(stringResource(R.string.sdk_max_version, sdk))
|
||||
}
|
||||
}
|
||||
if (sdkString.isNotEmpty())
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = stringResource(R.string.sdk_versions_colon, sdkString),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
}
|
||||
item.version.packageManifest.nativecode?.let { nativeCode ->
|
||||
if (nativeCode.isNotEmpty())
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text =
|
||||
stringResource(R.string.architectures_colon, nativeCode.joinToString(", ")),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
item.version.signer?.let { signer ->
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = stringResource(R.string.signer_colon, signer.sha256[0].substring(0..15)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.showInstallButton) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -211,13 +210,15 @@ fun Version(
|
||||
coroutineScope.launch { scrollUp() }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(top = 8.dp, end = 16.dp).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showMeteredDialog)
|
||||
MeteredConnectionDialog(
|
||||
numBytes = item.version.size,
|
||||
onConfirm = {
|
||||
onConfirm = { notWarnWhenMetered ->
|
||||
if (notWarnWhenMetered) onNotWarnWhenMetered()
|
||||
installAction(item.version as AppVersion)
|
||||
coroutineScope.launch { scrollUp() }
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.fdroid.ui.discover
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -11,11 +12,14 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -27,6 +31,7 @@ import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.utils.AsyncShimmerImage
|
||||
import org.fdroid.ui.utils.InstalledBadge
|
||||
@@ -41,20 +46,28 @@ fun AppCarousel(
|
||||
onTitleTap: () -> Unit,
|
||||
onAppTap: (AppDiscoverItem) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = spacedBy(8.dp), modifier = modifier) {
|
||||
Column(verticalArrangement = spacedBy(10.dp), modifier = modifier) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onTitleTap)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onTitleTap).padding(horizontal = 16.dp),
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.headlineSmall)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
modifier = Modifier.semantics { hideFromAccessibility() },
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(text = title, style = MaterialTheme.typography.titleMediumEmphasized, fontSize = 20.sp)
|
||||
IconButton(
|
||||
onClick = onTitleTap,
|
||||
shape = CircleShape,
|
||||
colors =
|
||||
IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
),
|
||||
modifier = Modifier.size(48.dp).padding(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
modifier = Modifier.semantics { hideFromAccessibility() },
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -82,7 +95,13 @@ fun AppBox(app: AppDiscoverItem, onAppTap: (AppDiscoverItem) -> Unit) {
|
||||
},
|
||||
)
|
||||
}
|
||||
Text(text = app.name, style = MaterialTheme.typography.bodySmall, minLines = 2, maxLines = 2)
|
||||
Text(
|
||||
text = app.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
package org.fdroid.ui.discover
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.fdroid.R
|
||||
import org.fdroid.ui.categories.CategoryList
|
||||
import org.fdroid.ui.lists.AppListType
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun DiscoverContent(
|
||||
discoverModel: LoadedDiscoverModel,
|
||||
onListTap: (AppListType) -> Unit,
|
||||
@@ -21,7 +23,7 @@ fun DiscoverContent(
|
||||
onNav: (NavKey) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Column(verticalArrangement = spacedBy(18.dp), modifier = modifier) {
|
||||
AnimatedVisibility(discoverModel.newApps.isNotEmpty()) {
|
||||
val listNew = AppListType.New(stringResource(R.string.app_list_new))
|
||||
AppCarousel(
|
||||
|
||||
@@ -2,6 +2,11 @@ package org.fdroid.ui.discover
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ACTION_LOCALE_CHANGED
|
||||
import android.content.IntentFilter
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
@@ -16,10 +21,13 @@ import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
@@ -49,8 +57,8 @@ constructor(
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val moleculeScope =
|
||||
CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
|
||||
private val collator = Collator.getInstance(Locale.getDefault())
|
||||
|
||||
private val localeListFlow = MutableStateFlow(LocaleListCompat.getDefault())
|
||||
private val newAppsFlow = db.getAppDao().getNewAppsFlow()
|
||||
private val recentlyUpdatedAppsFlow = db.getAppDao().getRecentlyUpdatedAppsFlow()
|
||||
private val mostDownloadedApps =
|
||||
@@ -68,8 +76,10 @@ constructor(
|
||||
db.getAppDao().getAppsFlow(packageNames).collect { apps -> emit(apps) }
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
private val dbCategories = db.getRepositoryDao().getLiveCategories().asFlow()
|
||||
private val categories =
|
||||
db.getRepositoryDao().getLiveCategories().asFlow().map { categories ->
|
||||
combine(localeListFlow, dbCategories) { localeList, categories ->
|
||||
val collator = Collator.getInstance(Locale.getDefault())
|
||||
categories
|
||||
.map { category ->
|
||||
CategoryItem(id = category.id, name = category.getName(localeList) ?: "Unknown Category")
|
||||
@@ -81,7 +91,6 @@ constructor(
|
||||
repos?.any { it.enabled && it.errorCount >= 5 } ?: false
|
||||
}
|
||||
|
||||
val localeList = LocaleListCompat.getDefault()
|
||||
val discoverModel: StateFlow<DiscoverModel> by
|
||||
lazy(LazyThreadSafetyMode.NONE) {
|
||||
@SuppressLint("StateFlowValueCalledInComposition") // see comment below
|
||||
@@ -103,4 +112,24 @@ constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val localeChangedReceiver = LocaleChangedReceiver()
|
||||
|
||||
init {
|
||||
app.registerReceiver(localeChangedReceiver, IntentFilter(ACTION_LOCALE_CHANGED))
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
app.unregisterReceiver(localeChangedReceiver)
|
||||
}
|
||||
|
||||
private inner class LocaleChangedReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == ACTION_LOCALE_CHANGED) {
|
||||
val localeList = LocaleListCompat.getDefault()
|
||||
log.info { "Locale list has changed: $localeList" }
|
||||
localeListFlow.update { localeList }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +52,10 @@ fun FirstStart(
|
||||
// TODO it would be nice to surface normal update errors better and also let the user retry
|
||||
}
|
||||
Column(verticalArrangement = Center, modifier = modifier) {
|
||||
if ((!networkState.isOnline || networkState.isMetered) && !override) {
|
||||
if ((!networkState.isOnline || networkState.showWarningDialog) && !override) {
|
||||
// offline or metered, not overridden
|
||||
val res =
|
||||
if (networkState.isMetered) {
|
||||
if (networkState.showWarningDialog) {
|
||||
stringResource(R.string.first_start_metered)
|
||||
} else {
|
||||
stringResource(R.string.first_start_offline)
|
||||
|
||||
@@ -17,8 +17,8 @@ import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ManageSearch
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -51,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.viktormykhailiv.compose.hints.HintHost
|
||||
import com.viktormykhailiv.compose.hints.HintProperties
|
||||
import com.viktormykhailiv.compose.hints.hintAnchor
|
||||
import com.viktormykhailiv.compose.hints.rememberHint
|
||||
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
|
||||
@@ -61,7 +62,7 @@ import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.search.TopSearchBar
|
||||
import org.fdroid.ui.utils.BackButton
|
||||
import org.fdroid.ui.utils.BigLoadingIndicator
|
||||
import org.fdroid.ui.utils.OnboardingCard
|
||||
import org.fdroid.ui.utils.OnboardingPopupCard
|
||||
import org.fdroid.ui.utils.TopAppBarButton
|
||||
import org.fdroid.ui.utils.appListItems
|
||||
import org.fdroid.ui.utils.getAppListInfo
|
||||
@@ -80,22 +81,22 @@ fun AppList(
|
||||
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
val hintController = rememberHintController(overlay = getHintOverlayColor())
|
||||
val hint = rememberHint {
|
||||
OnboardingCard(
|
||||
title = stringResource(R.string.onboarding_app_list_filter_title),
|
||||
message = stringResource(R.string.onboarding_app_list_filter_message),
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
onGotIt = {
|
||||
appListInfo.actions.onOnboardingSeen()
|
||||
hintController.dismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
val hint =
|
||||
rememberHint(HintProperties(dismissOnClickOutside = false)) {
|
||||
OnboardingPopupCard(
|
||||
title = stringResource(R.string.onboarding_app_list_filter_title),
|
||||
message = stringResource(R.string.onboarding_app_list_filter_message),
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
onGotIt = {
|
||||
appListInfo.actions.onOnboardingSeen()
|
||||
hintController.dismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
val hintAnchor = rememberHintAnchorState(hint)
|
||||
LaunchedEffect(appListInfo.showOnboarding) {
|
||||
if (appListInfo.showOnboarding) {
|
||||
hintController.show(hintAnchor)
|
||||
appListInfo.actions.onOnboardingSeen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +132,7 @@ fun AppList(
|
||||
},
|
||||
actions = {
|
||||
TopAppBarButton(
|
||||
imageVector = Icons.Filled.Search,
|
||||
imageVector = Icons.AutoMirrored.Default.ManageSearch,
|
||||
contentDescription = stringResource(R.string.menu_search),
|
||||
onClick = { searchActive = true },
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ fun BottomBar(model: MainModel, currentNavKey: NavKey, onNav: (MainNavKey) -> Un
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary),
|
||||
),
|
||||
onClick = { if (dest != currentNavKey) onNav(dest) },
|
||||
onClick = { onNav(dest) },
|
||||
modifier =
|
||||
Modifier.semantics {
|
||||
if (dest == NavigationKey.MyApps) {
|
||||
@@ -83,7 +83,7 @@ fun NavigationRail(
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary),
|
||||
),
|
||||
onClick = { if (dest != currentNavKey) onNav(dest) },
|
||||
onClick = { onNav(dest) },
|
||||
modifier =
|
||||
Modifier.semantics {
|
||||
if (dest == NavigationKey.MyApps) {
|
||||
|
||||
@@ -46,6 +46,8 @@ fun EntryProviderScope<NavKey>.repoEntry(navigator: Navigator, isBigScreen: Bool
|
||||
override fun onRepositoryEnabled(repoId: Long, enabled: Boolean) =
|
||||
viewModel.onRepositoryEnabled(repoId, enabled)
|
||||
|
||||
override fun onNotWarnWhenMetered() = viewModel.onNotWarnWhenMetered()
|
||||
|
||||
override fun onAddRepo() {
|
||||
navigator.navigate(NavigationKey.AddRepo())
|
||||
}
|
||||
@@ -94,6 +96,7 @@ fun EntryProviderScope<NavKey>.repoEntry(navigator: Navigator, isBigScreen: Bool
|
||||
networkStateFlow = viewModel.networkState,
|
||||
proxyConfig = viewModel.proxyConfig,
|
||||
onFetchRepo = viewModel::onFetchRepo,
|
||||
onNotWarnWhenMetered = viewModel::onNotWarnWhenMetered,
|
||||
onAddRepo = viewModel::addFetchedRepository,
|
||||
onExistingRepo = { repoId ->
|
||||
navigator.goBack()
|
||||
|
||||
@@ -34,8 +34,10 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.viktormykhailiv.compose.hints.HintHost
|
||||
import com.viktormykhailiv.compose.hints.HintProperties
|
||||
import com.viktormykhailiv.compose.hints.rememberHint
|
||||
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
|
||||
import com.viktormykhailiv.compose.hints.rememberHintController
|
||||
@@ -43,7 +45,7 @@ import org.fdroid.R
|
||||
import org.fdroid.download.NetworkState
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.utils.BigLoadingIndicator
|
||||
import org.fdroid.ui.utils.OnboardingCard
|
||||
import org.fdroid.ui.utils.OnboardingPopupCard
|
||||
import org.fdroid.ui.utils.getHintOverlayColor
|
||||
import org.fdroid.ui.utils.getRepositoriesInfo
|
||||
import org.fdroid.ui.utils.repoItems
|
||||
@@ -52,24 +54,24 @@ import org.fdroid.ui.utils.repoItems
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
fun Repositories(info: RepositoryInfo, isBigScreen: Boolean, onBackClicked: () -> Unit) {
|
||||
val hintController = rememberHintController(overlay = getHintOverlayColor())
|
||||
val hint = rememberHint {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||
OnboardingCard(
|
||||
title = stringResource(R.string.repo_list_info_title),
|
||||
message = stringResource(R.string.repo_list_info_text),
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
onGotIt = {
|
||||
info.onOnboardingSeen()
|
||||
hintController.dismiss()
|
||||
},
|
||||
)
|
||||
val hint =
|
||||
rememberHint(HintProperties(dismissOnClickOutside = false)) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||
OnboardingPopupCard(
|
||||
title = stringResource(R.string.repo_list_info_title),
|
||||
message = stringResource(R.string.repo_list_info_text),
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
onGotIt = {
|
||||
info.onOnboardingSeen()
|
||||
hintController.dismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val hintAnchor = rememberHintAnchorState(hint)
|
||||
LaunchedEffect(info.model.showOnboarding) {
|
||||
if (!isBigScreen && info.model.showOnboarding) {
|
||||
hintController.show(hintAnchor)
|
||||
info.onOnboardingSeen()
|
||||
}
|
||||
}
|
||||
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
|
||||
@@ -153,8 +155,8 @@ fun RepositoriesScaffoldLoadingPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
|
||||
@Composable
|
||||
@PreviewLightDark
|
||||
private fun RepositoriesScaffoldPreview() {
|
||||
HintHost {
|
||||
FDroidContent {
|
||||
|
||||
@@ -3,8 +3,11 @@ package org.fdroid.ui.repositories
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.plus
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
@@ -12,7 +15,12 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -21,6 +29,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
@@ -85,7 +94,7 @@ fun RepositoriesList(
|
||||
isSelected = isSelected,
|
||||
onRepoEnabled = { enabled ->
|
||||
if (enabled) {
|
||||
if (info.model.networkState.isMetered)
|
||||
if (info.model.networkState.showWarningDialog)
|
||||
showMeteredDialog = { info.onRepositoryEnabled(repoItem.repoId, true) }
|
||||
else info.onRepositoryEnabled(repoItem.repoId, true)
|
||||
} else {
|
||||
@@ -108,7 +117,33 @@ fun RepositoriesList(
|
||||
val repoId = showDisableRepoDialog
|
||||
if (repoId != null) {
|
||||
AlertDialog(
|
||||
text = { Text(text = stringResource(R.string.repo_disable_warning)) },
|
||||
title = { Text(text = stringResource(R.string.repo_disable_dialog_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = spacedBy(8.dp)) {
|
||||
ElevatedCard(
|
||||
colors =
|
||||
CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.repo_disable_dialog_warning),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.repo_disable_dialog_text))
|
||||
}
|
||||
},
|
||||
onDismissRequest = { showDisableRepoDialog = null },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
@@ -135,7 +170,10 @@ fun RepositoriesList(
|
||||
if (meteredLambda != null)
|
||||
MeteredConnectionDialog(
|
||||
numBytes = null,
|
||||
onConfirm = { meteredLambda() },
|
||||
onConfirm = { notWarnWhenMetered ->
|
||||
if (notWarnWhenMetered) info.onNotWarnWhenMetered()
|
||||
meteredLambda()
|
||||
},
|
||||
onDismiss = { showMeteredDialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,10 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun onNotWarnWhenMetered() {
|
||||
settingsManager.onDontWarnOnMeteredNetwork()
|
||||
}
|
||||
|
||||
fun onRepositoriesMoved(fromRepoId: Long, toRepoId: Long) {
|
||||
log.info { "onRepositoriesMoved($fromRepoId, $toRepoId)" }
|
||||
repoSortingMap.update {
|
||||
|
||||
@@ -12,6 +12,8 @@ interface RepositoryInfo {
|
||||
|
||||
fun onRepositoryEnabled(repoId: Long, enabled: Boolean)
|
||||
|
||||
fun onNotWarnWhenMetered()
|
||||
|
||||
fun onAddRepo()
|
||||
|
||||
fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.fdroid.ui.repositories
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import org.fdroid.repo.RepoUpdateWorker
|
||||
|
||||
private val TAG = UpdateRepoActivity::class.java.simpleName
|
||||
|
||||
/**
|
||||
* This activity allows OS components to trigger a repository update. One known use-case for this is
|
||||
* to do an initial repository update during SetupWizard, so app data is available when needed, e.g.
|
||||
* for restoring app backups.
|
||||
*/
|
||||
class UpdateRepoActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (intent.action != "org.fdroid.action.UPDATE_REPOS") {
|
||||
Log.w(TAG, "Unknown action: ${intent.action}")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Intent received, updating repos...")
|
||||
RepoUpdateWorker.updateNow(this)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ fun AddRepo(
|
||||
networkStateFlow: StateFlow<NetworkState>,
|
||||
proxyConfig: ProxyConfig?,
|
||||
onFetchRepo: (String) -> Unit,
|
||||
onNotWarnWhenMetered: () -> Unit,
|
||||
onAddRepo: () -> Unit,
|
||||
onExistingRepo: (Long) -> Unit,
|
||||
onRepoAdded: (String, Long) -> Unit,
|
||||
@@ -74,7 +75,7 @@ fun AddRepo(
|
||||
when (state) {
|
||||
None -> {
|
||||
val networkState = networkStateFlow.collectAsStateWithLifecycle().value
|
||||
AddRepoIntroContent(networkState, onFetchRepo, paddingValues)
|
||||
AddRepoIntroContent(networkState, onFetchRepo, onNotWarnWhenMetered, paddingValues)
|
||||
}
|
||||
is Fetching -> {
|
||||
if (state.receivedRepo == null) {
|
||||
|
||||
@@ -88,6 +88,7 @@ import org.fdroid.ui.utils.startActivitySafe
|
||||
fun AddRepoIntroContent(
|
||||
networkState: NetworkState,
|
||||
onFetchRepo: (String) -> Unit,
|
||||
onNotWarnWhenMetered: () -> Unit,
|
||||
paddingValues: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -150,7 +151,7 @@ fun AddRepoIntroContent(
|
||||
Unit
|
||||
}
|
||||
}
|
||||
if (networkState.isMetered) showMeteredDialog = scanLambda else scanLambda()
|
||||
if (networkState.showWarningDialog) showMeteredDialog = scanLambda else scanLambda()
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(
|
||||
@@ -229,7 +230,7 @@ fun AddRepoIntroContent(
|
||||
FDroidButton(
|
||||
text = stringResource(R.string.repo_add_add),
|
||||
onClick = {
|
||||
if (networkState.isMetered)
|
||||
if (networkState.showWarningDialog)
|
||||
showMeteredDialog = { onFetchRepo(textState.value.text) }
|
||||
else onFetchRepo(textState.value.text)
|
||||
},
|
||||
@@ -244,7 +245,10 @@ fun AddRepoIntroContent(
|
||||
if (meteredLambda != null)
|
||||
MeteredConnectionDialog(
|
||||
numBytes = null,
|
||||
onConfirm = { meteredLambda() },
|
||||
onConfirm = { notWarnWhenMetered ->
|
||||
if (notWarnWhenMetered) onNotWarnWhenMetered()
|
||||
meteredLambda()
|
||||
},
|
||||
onDismiss = { showMeteredDialog = null },
|
||||
)
|
||||
}
|
||||
@@ -254,7 +258,7 @@ fun AddRepoIntroContent(
|
||||
private fun Preview() {
|
||||
FDroidContent {
|
||||
val networkStateFlow = MutableStateFlow(NetworkState(isOnline = true, isMetered = false))
|
||||
AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {}
|
||||
AddRepo(None, networkStateFlow, null, {}, {}, {}, {}, { _, _ -> }, {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +267,6 @@ private fun Preview() {
|
||||
private fun PreviewNight() {
|
||||
FDroidContent {
|
||||
val networkStateFlow = MutableStateFlow(NetworkState(isOnline = false, isMetered = false))
|
||||
AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {}
|
||||
AddRepo(None, networkStateFlow, null, {}, {}, {}, {}, { _, _ -> }, {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class AddRepoViewModel
|
||||
constructor(
|
||||
app: Application,
|
||||
networkMonitor: NetworkMonitor,
|
||||
settingsManager: SettingsManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val repoManager: RepoManager,
|
||||
private val updateManager: UpdatesManager,
|
||||
@param:IoDispatcher private val ioScope: CoroutineScope,
|
||||
@@ -74,4 +74,8 @@ constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNotWarnWhenMetered() {
|
||||
settingsManager.onDontWarnOnMeteredNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.viktormykhailiv.compose.hints.HintHost
|
||||
import com.viktormykhailiv.compose.hints.HintProperties
|
||||
import com.viktormykhailiv.compose.hints.rememberHint
|
||||
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
|
||||
import com.viktormykhailiv.compose.hints.rememberHintController
|
||||
@@ -39,7 +40,7 @@ import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.utils.BackButton
|
||||
import org.fdroid.ui.utils.BigLoadingIndicator
|
||||
import org.fdroid.ui.utils.MeteredConnectionDialog
|
||||
import org.fdroid.ui.utils.OnboardingCard
|
||||
import org.fdroid.ui.utils.OnboardingPopupCard
|
||||
import org.fdroid.ui.utils.TopAppBarButton
|
||||
import org.fdroid.ui.utils.TopAppBarOverflowButton
|
||||
import org.fdroid.ui.utils.getHintOverlayColor
|
||||
@@ -56,24 +57,24 @@ fun RepoDetails(
|
||||
val repo = info.model.repo
|
||||
|
||||
val hintController = rememberHintController(overlay = getHintOverlayColor())
|
||||
val hint = rememberHint {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||
OnboardingCard(
|
||||
title = stringResource(R.string.repo_details),
|
||||
message = stringResource(R.string.repo_details_info_text),
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
onGotIt = {
|
||||
info.actions.onOnboardingSeen()
|
||||
hintController.dismiss()
|
||||
},
|
||||
)
|
||||
val hint =
|
||||
rememberHint(HintProperties(dismissOnClickOutside = false)) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||
OnboardingPopupCard(
|
||||
title = stringResource(R.string.repo_details),
|
||||
message = stringResource(R.string.repo_details_info_text),
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
onGotIt = {
|
||||
info.actions.onOnboardingSeen()
|
||||
hintController.dismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val hintAnchor = rememberHintAnchorState(hint)
|
||||
LaunchedEffect(info.model.showOnboarding) {
|
||||
if (info.model.showOnboarding) {
|
||||
hintController.show(hintAnchor)
|
||||
info.actions.onOnboardingSeen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +96,10 @@ fun RepoDetails(
|
||||
if (meteredLambda != null)
|
||||
MeteredConnectionDialog(
|
||||
numBytes = null,
|
||||
onConfirm = { meteredLambda() },
|
||||
onConfirm = { notWarnWhenMetered ->
|
||||
if (notWarnWhenMetered) info.actions.onNotWarnWhenMetered()
|
||||
meteredLambda()
|
||||
},
|
||||
onDismiss = { showMeteredDialog = null },
|
||||
)
|
||||
Scaffold(
|
||||
@@ -120,7 +124,7 @@ fun RepoDetails(
|
||||
contentDescription = stringResource(R.string.repo_force_update),
|
||||
enabled = info.model.isUpdateButtonEnabled,
|
||||
onClick = {
|
||||
if (info.model.networkState.isMetered)
|
||||
if (info.model.networkState.showWarningDialog)
|
||||
showMeteredDialog = { RepoUpdateWorker.updateNow(context, repo.repoId) }
|
||||
else RepoUpdateWorker.updateNow(context, repo.repoId)
|
||||
},
|
||||
|
||||
@@ -32,6 +32,8 @@ interface RepoDetailsActions {
|
||||
|
||||
fun setArchiveRepoEnabled(enabled: Boolean)
|
||||
|
||||
fun onNotWarnWhenMetered()
|
||||
|
||||
fun onOnboardingSeen()
|
||||
|
||||
suspend fun generateQrCode(repo: Repository): Bitmap? {
|
||||
|
||||
@@ -154,6 +154,10 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotWarnWhenMetered() {
|
||||
settingsManager.onDontWarnOnMeteredNetwork()
|
||||
}
|
||||
|
||||
override fun onOnboardingSeen() = onboardingManager.onRepoDetailsOnboardingSeen()
|
||||
|
||||
private fun Repository.archiveState(): ArchiveState {
|
||||
|
||||
@@ -23,7 +23,7 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fdroid.R
|
||||
import org.fdroid.search.SEARCH_THRESHOLD
|
||||
import org.fdroid.search.SearchHelper.isSearchable
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
|
||||
@@ -41,10 +41,10 @@ fun AppSearchInputField(
|
||||
snapshotFlow { textFieldState.text }
|
||||
.distinctUntilChanged()
|
||||
.debounce(500)
|
||||
.collectLatest {
|
||||
if (it.isEmpty()) {
|
||||
.collectLatest { query ->
|
||||
if (query.isEmpty()) {
|
||||
onSearchCleared()
|
||||
} else if (it.length >= SEARCH_THRESHOLD) {
|
||||
} else if (query.isSearchable()) {
|
||||
onSearch(textFieldState.text.toString())
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ fun AppSearchInputField(
|
||||
searchBarState = searchBarState,
|
||||
textFieldState = textFieldState,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
onSearch = { scope.launch { onSearch(it) } },
|
||||
onSearch = { if (it.isSearchable()) scope.launch { onSearch(it) } },
|
||||
placeholder = { Text(stringResource(R.string.search_placeholder)) },
|
||||
trailingIcon = {
|
||||
if (textFieldState.text.isNotEmpty()) {
|
||||
|
||||
@@ -7,42 +7,74 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.fdroid.R
|
||||
import org.fdroid.search.SavedSearch
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
import org.fdroid.ui.utils.TopAppBarOverflowButton
|
||||
import org.fdroid.ui.utils.appListItems
|
||||
import org.fdroid.ui.utils.categoryItems
|
||||
import org.fdroid.ui.utils.getSearchInfo
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun GlobalSearch(
|
||||
info: SearchInfo,
|
||||
textFieldState: TextFieldState = rememberTextFieldState(),
|
||||
searchResults: SearchResults?,
|
||||
savedSearches: List<SavedSearch>? = null,
|
||||
onSearch: suspend (String) -> Unit,
|
||||
onClearSavedSearches: () -> Unit,
|
||||
onNav: (NavigationKey) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onSearchCleared: () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(info.showKeyboard) {
|
||||
// auto-reset showKeyboard, so it functions like an event
|
||||
if (info.showKeyboard) info.onKeyboardShown()
|
||||
}
|
||||
val searchResults = info.searchResults
|
||||
val showKeyboard =
|
||||
info.showKeyboard || // either show keyboard because user double tapped the search icon
|
||||
(info.autoShowKeyboard && // or auto-show is activated
|
||||
(searchResults == null || // but only if no search results are shown
|
||||
searchResults.apps.isEmpty() && searchResults.categories.isEmpty()))
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopSearchBar(
|
||||
searchFieldState = textFieldState,
|
||||
shouldRequestFocus = // only show keyboard if there are no results to show
|
||||
searchResults == null ||
|
||||
searchResults.apps.isEmpty() && searchResults.categories.isEmpty(),
|
||||
onSearch = onSearch,
|
||||
onSearchCleared = onSearchCleared,
|
||||
shouldRequestFocus = showKeyboard,
|
||||
onSearch = info.actions::onSearch,
|
||||
onSearchCleared = info.actions::onSearchCleared,
|
||||
onHideSearch = onBack,
|
||||
actions = {
|
||||
if (textFieldState.text.isEmpty()) {
|
||||
TopAppBarOverflowButton { onDismiss ->
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Keyboard, null) },
|
||||
text = { Text(stringResource(R.string.search_auto_keyboard)) },
|
||||
trailingIcon = {
|
||||
Checkbox(checked = info.autoShowKeyboard, onCheckedChange = null)
|
||||
},
|
||||
onClick = {
|
||||
info.actions.setAutoShowKeyboard(!info.autoShowKeyboard)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
@@ -59,8 +91,9 @@ fun GlobalSearch(
|
||||
paddingValues = paddingValues,
|
||||
searchResults = searchResults,
|
||||
textFieldState = textFieldState,
|
||||
savedSearches = savedSearches,
|
||||
onClearSavedSearches = onClearSavedSearches,
|
||||
savedSearches = info.savedSearches,
|
||||
categories = info.categories,
|
||||
onClearSavedSearches = info.actions::onClearSearchHistory,
|
||||
onNav = onNav,
|
||||
)
|
||||
}
|
||||
@@ -72,7 +105,9 @@ fun GlobalSearch(
|
||||
private fun AppsSearchLoadingPreview() {
|
||||
FDroidContent {
|
||||
val textFieldState = rememberTextFieldState("foo bar")
|
||||
Box(Modifier.fillMaxSize()) { GlobalSearch(textFieldState, null, null, {}, {}, {}, {}, {}) }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GlobalSearch(info = getSearchInfo(), textFieldState = textFieldState, onNav = {}, onBack = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,14 +121,10 @@ private fun AppsSearchEmptyStatePreview() {
|
||||
listOf(SavedSearch(1, "foo"), SavedSearch(2, "foo bar"), SavedSearch(3, "foobar"))
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GlobalSearch(
|
||||
info = getSearchInfo(null, savedSearches, categoryItems),
|
||||
textFieldState = textFieldState,
|
||||
searchResults = null,
|
||||
savedSearches = savedSearches,
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -107,14 +138,10 @@ private fun AppsSearchNoResultsPreview() {
|
||||
val textFieldState = rememberTextFieldState("foo")
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GlobalSearch(
|
||||
info = getSearchInfo(SearchResults(emptyList(), emptyList()), emptyList(), emptyList()),
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(emptyList(), emptyList()),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -128,14 +155,10 @@ private fun AppsSearchOnlyCategoriesPreview() {
|
||||
val textFieldState = rememberTextFieldState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GlobalSearch(
|
||||
info = getSearchInfo(SearchResults(emptyList(), categoryItems.subList(3, 5)), emptyList()),
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(emptyList(), categoryItems.subList(3, 5)),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -149,14 +172,10 @@ private fun AppsSearchPreview() {
|
||||
val textFieldState = rememberTextFieldState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GlobalSearch(
|
||||
info = getSearchInfo(SearchResults(appListItems, categoryItems.subList(0, 4)), emptyList()),
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(appListItems, categoryItems.subList(0, 4)),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
package org.fdroid.ui.search
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -23,6 +17,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -32,51 +27,41 @@ import org.fdroid.R
|
||||
import org.fdroid.search.SavedSearch
|
||||
import org.fdroid.ui.FDroidContent
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
fun PastSearches(
|
||||
fun LazyListScope.pastSearches(
|
||||
savedSearches: List<SavedSearch>,
|
||||
onSearch: (String) -> Unit,
|
||||
onClearSavedSearches: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
paddingValues: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = paddingValues) {
|
||||
item {
|
||||
Row {
|
||||
Text(
|
||||
text = stringResource(R.string.search_history),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp).weight(1f),
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.search_history),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp).weight(1f),
|
||||
)
|
||||
TextButton(onClick = onClearSavedSearches, modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Text(stringResource(R.string.clear))
|
||||
}
|
||||
}
|
||||
}
|
||||
items(savedSearches) { item ->
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.History,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(8.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onClearSavedSearches,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.clear))
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = { onSearch(item.query) },
|
||||
modifier = Modifier.fillMaxWidth().animateItem(),
|
||||
) {
|
||||
Text(item.query)
|
||||
}
|
||||
items(savedSearches) { item ->
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.History,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(8.dp),
|
||||
)
|
||||
},
|
||||
onClick = { onSearch(item.query) },
|
||||
modifier = Modifier.fillMaxWidth().animateItem(),
|
||||
) {
|
||||
Text(item.query)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) }
|
||||
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +71,8 @@ private fun Preview() {
|
||||
FDroidContent {
|
||||
val savedSearches =
|
||||
listOf(SavedSearch(1, "foo"), SavedSearch(2, "foo bar"), SavedSearch(3, "foobar"))
|
||||
PastSearches(
|
||||
savedSearches = savedSearches,
|
||||
onClearSavedSearches = {},
|
||||
onSearch = {},
|
||||
)
|
||||
LazyColumn {
|
||||
pastSearches(savedSearches = savedSearches, onClearSavedSearches = {}, onSearch = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
app/src/main/kotlin/org/fdroid/ui/search/SearchInfo.kt
Normal file
24
app/src/main/kotlin/org/fdroid/ui/search/SearchInfo.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
package org.fdroid.ui.search
|
||||
|
||||
import org.fdroid.search.SavedSearch
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
|
||||
interface SearchInfo {
|
||||
val searchResults: SearchResults?
|
||||
val savedSearches: List<SavedSearch>?
|
||||
val categories: List<CategoryItem>?
|
||||
val autoShowKeyboard: Boolean
|
||||
val showKeyboard: Boolean
|
||||
val actions: SearchActions
|
||||
fun onKeyboardShown()
|
||||
}
|
||||
|
||||
interface SearchActions {
|
||||
suspend fun onSearch(term: String)
|
||||
|
||||
fun onSearchCleared()
|
||||
|
||||
fun onClearSearchHistory()
|
||||
|
||||
fun setAutoShowKeyboard(autoShow: Boolean)
|
||||
}
|
||||
@@ -23,12 +23,13 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fdroid.R
|
||||
import org.fdroid.search.SEARCH_THRESHOLD
|
||||
import org.fdroid.search.SavedSearch
|
||||
import org.fdroid.search.SearchHelper.isSearchable
|
||||
import org.fdroid.ui.categories.CategoryChip
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
import org.fdroid.ui.categories.ChipFlowRow
|
||||
@@ -47,6 +48,7 @@ fun SearchResults(
|
||||
searchResults: SearchResults?,
|
||||
textFieldState: TextFieldState,
|
||||
savedSearches: List<SavedSearch>?,
|
||||
categories: List<CategoryItem>?,
|
||||
onClearSavedSearches: () -> Unit,
|
||||
onNav: (NavigationKey) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -55,20 +57,29 @@ fun SearchResults(
|
||||
val listState =
|
||||
rememberSaveable(searchResults, saver = LazyListState.Saver) { LazyListState(0, 0) }
|
||||
if (searchResults == null) {
|
||||
if (textFieldState.text.length >= SEARCH_THRESHOLD) {
|
||||
if (textFieldState.text.isSearchable()) {
|
||||
BigLoadingIndicator(modifier.padding(paddingValues).imePadding())
|
||||
} else {
|
||||
if (!savedSearches.isNullOrEmpty()) {
|
||||
PastSearches(
|
||||
savedSearches = savedSearches,
|
||||
onSearch = { textFieldState.edit { replace(0, length, it) } },
|
||||
onClearSavedSearches = onClearSavedSearches,
|
||||
modifier = modifier,
|
||||
paddingValues = paddingValues,
|
||||
)
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = paddingValues) {
|
||||
if (!savedSearches.isNullOrEmpty()) {
|
||||
pastSearches(
|
||||
savedSearches = savedSearches,
|
||||
onSearch = {
|
||||
textFieldState.edit { replace(0, length, it) }
|
||||
keyboardController?.hide()
|
||||
},
|
||||
onClearSavedSearches = onClearSavedSearches,
|
||||
)
|
||||
}
|
||||
if (!categories.isNullOrEmpty()) {
|
||||
item { CategoriesFlowRow(categories, onNav, modifier = Modifier.padding(top = 16.dp)) }
|
||||
}
|
||||
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) }
|
||||
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) }
|
||||
}
|
||||
}
|
||||
} else if (searchResults.apps.isEmpty() && textFieldState.text.length >= SEARCH_THRESHOLD) {
|
||||
} else if (searchResults.apps.isEmpty() && textFieldState.text.isSearchable()) {
|
||||
Column(modifier = modifier.padding(paddingValues)) {
|
||||
if (searchResults.categories.isNotEmpty()) {
|
||||
CategoriesFlowRow(searchResults.categories, onNav)
|
||||
@@ -121,8 +132,12 @@ fun SearchResults(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoriesFlowRow(categories: List<CategoryItem>, onNav: (NavigationKey) -> Unit) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
private fun CategoriesFlowRow(
|
||||
categories: List<CategoryItem>,
|
||||
onNav: (NavigationKey) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.padding(horizontal = 8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.main_menu__categories),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
|
||||
@@ -6,19 +6,35 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import jakarta.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fdroid.search.SearchHelper.isSearchable
|
||||
import org.fdroid.search.SearchManager
|
||||
import org.fdroid.settings.SettingsManager
|
||||
|
||||
@HiltViewModel
|
||||
class SearchViewModel
|
||||
@Inject
|
||||
constructor(app: Application, private val searchManager: SearchManager) : AndroidViewModel(app) {
|
||||
constructor(
|
||||
app: Application,
|
||||
private val searchManager: SearchManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
) : AndroidViewModel(app), SearchActions {
|
||||
|
||||
val searchResults = searchManager.searchResults
|
||||
val savedSearchesFlow = searchManager.savedSearches
|
||||
val categories = searchManager.categories
|
||||
val autoShowKeyboard = settingsManager.showSearchKeyboardFlow
|
||||
|
||||
suspend fun search(term: String) = searchManager.search(term)
|
||||
override suspend fun onSearch(term: String) {
|
||||
if (term.isSearchable()) searchManager.search(term)
|
||||
}
|
||||
|
||||
fun onSearchCleared() = searchManager.onSearchCleared()
|
||||
override fun onSearchCleared() = searchManager.onSearchCleared()
|
||||
|
||||
fun onClearSearchHistory() = viewModelScope.launch { searchManager.onClearSearchHistory() }
|
||||
override fun onClearSearchHistory() {
|
||||
viewModelScope.launch { searchManager.onClearSearchHistory() }
|
||||
}
|
||||
|
||||
override fun setAutoShowKeyboard(autoShow: Boolean) {
|
||||
settingsManager.setShowSearchKeyboard(autoShow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package org.fdroid.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Process
|
||||
import android.os.Process.myUid
|
||||
import android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_SHORT
|
||||
import androidx.annotation.StringRes
|
||||
@@ -68,8 +70,14 @@ constructor(
|
||||
sendToast(R.string.export_log_error)
|
||||
return@launch
|
||||
}
|
||||
// support for --pid was introduced in SDK 24
|
||||
val command = "logcat -d --pid=" + Process.myPid() + " *:V"
|
||||
val command = if (SDK_INT < 30) {
|
||||
// support for --pid was introduced in SDK 24
|
||||
"logcat -d --pid=${Process.myPid()} *:V"
|
||||
} else {
|
||||
// support for --uid was introduced in SDK 30 and is better,
|
||||
// because it gives logs before process death
|
||||
"logcat -d --uid=${myUid()} *:V"
|
||||
}
|
||||
try {
|
||||
application.contentResolver.openOutputStream(uri, "wt")?.use { outputStream ->
|
||||
getRuntime().exec(command).inputStream.use { inputStream ->
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
package org.fdroid.ui.utils
|
||||
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fdroid.R
|
||||
|
||||
@Composable
|
||||
fun MeteredConnectionDialog(numBytes: Long?, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
fun MeteredConnectionDialog(
|
||||
numBytes: Long?,
|
||||
onConfirm: (Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var dontShowAgain by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
title = { Text(text = stringResource(R.string.dialog_metered_title)) },
|
||||
text = {
|
||||
@@ -23,14 +39,26 @@ fun MeteredConnectionDialog(numBytes: Long?, onConfirm: () -> Unit, onDismiss: (
|
||||
val sizeStr = Formatter.formatFileSize(LocalContext.current, numBytes)
|
||||
stringResource(R.string.dialog_metered_text, sizeStr)
|
||||
}
|
||||
Text(text = s)
|
||||
Column {
|
||||
Text(text = s)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
) {
|
||||
Checkbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it })
|
||||
Text(
|
||||
text = stringResource(R.string.dialog_metered_dont_show),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onConfirm()
|
||||
onConfirm(dontShowAgain)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.fdroid.ui.utils
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
@@ -20,16 +23,38 @@ import androidx.compose.ui.unit.dp
|
||||
import org.fdroid.R
|
||||
import org.fdroid.ui.FDroidContent
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun OnboardingPopupCard(
|
||||
title: String,
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
buttonText: String = stringResource(R.string.got_it),
|
||||
onGotIt: () -> Unit = {},
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
OnboardingCard(
|
||||
title = title,
|
||||
message = message,
|
||||
modifier = modifier.widthIn(max = TooltipDefaults.richTooltipMaxWidth),
|
||||
buttonText = buttonText,
|
||||
buttonModifier = Modifier.focusRequester(focusRequester),
|
||||
onGotIt = onGotIt,
|
||||
)
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun OnboardingCard(
|
||||
title: String,
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
buttonText: String = stringResource(R.string.got_it),
|
||||
buttonModifier: Modifier = Modifier,
|
||||
onGotIt: () -> Unit = {},
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
ElevatedCard(modifier = modifier.widthIn(max = TooltipDefaults.richTooltipMaxWidth)) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
@@ -40,15 +65,15 @@ fun OnboardingCard(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onGotIt,
|
||||
modifier =
|
||||
Modifier.focusRequester(focusRequester).padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
) {
|
||||
Text(text = stringResource(R.string.got_it))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(
|
||||
onClick = onGotIt,
|
||||
modifier = buttonModifier.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
) {
|
||||
Text(text = buttonText)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.fdroid.index.v2.SignerV2
|
||||
import org.fdroid.install.InstallConfirmationState
|
||||
import org.fdroid.install.InstallState
|
||||
import org.fdroid.repo.RepoUpdateProgress
|
||||
import org.fdroid.search.SavedSearch
|
||||
import org.fdroid.ui.apps.AppUpdateItem
|
||||
import org.fdroid.ui.apps.AppWithIssueItem
|
||||
import org.fdroid.ui.apps.InstalledAppItem
|
||||
@@ -49,6 +50,9 @@ import org.fdroid.ui.repositories.details.RepoDetailsActions
|
||||
import org.fdroid.ui.repositories.details.RepoDetailsInfo
|
||||
import org.fdroid.ui.repositories.details.RepoDetailsModel
|
||||
import org.fdroid.ui.repositories.details.UserMirrorItem
|
||||
import org.fdroid.ui.search.SearchActions
|
||||
import org.fdroid.ui.search.SearchInfo
|
||||
import org.fdroid.ui.search.SearchResults
|
||||
|
||||
object Names {
|
||||
val randomName: String
|
||||
@@ -256,7 +260,9 @@ fun getAppDetailsActions() =
|
||||
onUninstallResult = { _ -> },
|
||||
onRepoChanged = {},
|
||||
onPreferredRepoChanged = {},
|
||||
onNotWarnWhenMetered = {},
|
||||
allowBetaVersions = {},
|
||||
onAntiFeaturesOnboardingSeen = {},
|
||||
ignoreAllUpdates = {},
|
||||
ignoreThisUpdate = {},
|
||||
shareApk = Intent(),
|
||||
@@ -407,8 +413,12 @@ fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo =
|
||||
|
||||
override fun ignoreAppIssue(item: AppWithIssueItem) {}
|
||||
|
||||
override fun onUpdatesHintSeen() {}
|
||||
|
||||
override fun onAppIssueHintSeen() {}
|
||||
|
||||
override fun onNotWarnWhenMetered() {}
|
||||
|
||||
override fun exportInstalledApps() {}
|
||||
}
|
||||
}
|
||||
@@ -495,6 +505,7 @@ internal val myAppsModel =
|
||||
lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3),
|
||||
),
|
||||
),
|
||||
showUpdatesHint = true,
|
||||
showAppIssueHint = true,
|
||||
sortOrder = AppListSortOrder.NAME,
|
||||
networkState = NetworkState(isOnline = false, isMetered = false),
|
||||
@@ -549,6 +560,8 @@ fun getRepositoriesInfo(model: RepositoryModel, currentRepositoryId: Long? = nul
|
||||
|
||||
override fun onRepositoryEnabled(repoId: Long, enabled: Boolean) {}
|
||||
|
||||
override fun onNotWarnWhenMetered() {}
|
||||
|
||||
override fun onAddRepo() {}
|
||||
|
||||
override fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) {}
|
||||
@@ -600,6 +613,8 @@ fun getRepoDetailsInfo(
|
||||
|
||||
override fun setArchiveRepoEnabled(enabled: Boolean) {}
|
||||
|
||||
override fun onNotWarnWhenMetered() {}
|
||||
|
||||
override fun onOnboardingSeen() {}
|
||||
}
|
||||
}
|
||||
@@ -623,3 +638,31 @@ fun getRepository(
|
||||
password = password,
|
||||
lastError = lastError,
|
||||
)
|
||||
|
||||
fun getSearchInfo(
|
||||
searchResults: SearchResults? = null,
|
||||
savedSearches: List<SavedSearch>? = null,
|
||||
categories: List<CategoryItem>? = null,
|
||||
) =
|
||||
object : SearchInfo {
|
||||
override val searchResults: SearchResults? = searchResults
|
||||
|
||||
override val savedSearches: List<SavedSearch>? = savedSearches
|
||||
|
||||
override val categories: List<CategoryItem>? = categories
|
||||
override val autoShowKeyboard: Boolean = false
|
||||
override val showKeyboard: Boolean = false
|
||||
|
||||
override val actions: SearchActions =
|
||||
object : SearchActions {
|
||||
override suspend fun onSearch(term: String) {}
|
||||
|
||||
override fun onSearchCleared() {}
|
||||
|
||||
override fun onClearSearchHistory() {}
|
||||
|
||||
override fun setAutoShowKeyboard(autoShow: Boolean) {}
|
||||
}
|
||||
|
||||
override fun onKeyboardShown() {}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import kotlinx.coroutines.withContext
|
||||
import org.fdroid.database.Repository
|
||||
|
||||
@Composable
|
||||
fun getHintOverlayColor() = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
||||
fun getHintOverlayColor() = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||
|
||||
fun Context.startActivitySafe(i: Intent?) {
|
||||
if (i == null) return
|
||||
|
||||
@@ -609,7 +609,6 @@
|
||||
<string name="repo_archive_toggle_description">أظهِر التطبيقات المؤرشفة والإصدارات القديمة من التطبيقات</string>
|
||||
<string name="repo_disable_warning_button">عطّل</string>
|
||||
<string name="app_details_repository_preferred">(مفضّل)</string>
|
||||
<string name="repo_disable_warning">سيؤدي تعطيل هذا المستودع إلى إزالة حالته \"المفضلة\" من جميع التطبيقات.</string>
|
||||
<string name="repo_mirror_add_info">عنوان URL الذي تحاول إضافته (%s) هو مرآة لمستودع موجود.</string>
|
||||
<string name="repo_add_repo_and_mirror">أضِف المستودع والمرآة</string>
|
||||
<string name="repo_view_repo">اعرض المستودع</string>
|
||||
|
||||
@@ -529,7 +529,6 @@
|
||||
<string name="status_inserting_x_apps">%3$s se app details save kiye ja rahe haì (%1$d/%2$d)</string>
|
||||
<string name="global_error_updating_repos">Update karte waqt error: %s</string>
|
||||
<string name="unknown">Anján</string>
|
||||
<string name="repo_disable_warning">Is bhanđár ko disable kar dene par iski \"cuni hui\" sthiti sabhi apps se haţ jáyegi.</string>
|
||||
<string name="repos_unchanged">Sabhi bhanđárè up-to-date haì</string>
|
||||
<string name="all_other_repos_fine">Báki kisi bhi bhanđár ne error nahì diya.</string>
|
||||
<string name="theme">Theme</string>
|
||||
|
||||
@@ -571,7 +571,6 @@
|
||||
<string name="repo_error_adding_archive">Архіўныя рэпазітары не могуць быць дададзены напрамую. Націсніце на рэпазітар са спісу і ўключыце ў ім архіў.</string>
|
||||
<string name="repo_archive_toggle_description">Адлюстроўваць архіўныя і састарэлыя версіі праграм</string>
|
||||
<string name="repo_archive_toggle">Архіў рэпазіторыя</string>
|
||||
<string name="repo_disable_warning">Адключэнне гэтага рэпазітара выдаліць яго статус «пераважны» з усіх праграм.</string>
|
||||
<string name="ipfsgw_title">IPFS-шлюзы</string>
|
||||
<string name="ipfsgw_add_title">Дадаць шлюз IPFS</string>
|
||||
<string name="ipfsgw_add_add">Дадаць</string>
|
||||
|
||||
@@ -557,7 +557,6 @@
|
||||
<string name="app_details_repository_button_prefer">Предпочитане на хранилището</string>
|
||||
<string name="repo_list_info_text">Хранилището е източник на приложения. Този списък показва всички добавени за момента хранилища. Изключените хранилища не се използват.\n\nАко едно приложение е налично в повече от едно хранилище, се използва хранилището, което е по-горе в списъка. Можете да подреждате хранилищата, с дълго задържане и влачене.</string>
|
||||
<string name="repo_error_adding_archive">Архив на хранилище не може да бъде добавен директно. Докоснете хранилище в списъка и го направете архивно.</string>
|
||||
<string name="repo_disable_warning">Изключване на това хранилище ще премахне състоянието „предпочитано“ от всички приложения.</string>
|
||||
<string name="repo_add_repo_and_mirror">Добавяне на хранилище или огледало</string>
|
||||
<string name="repo_view_repo">Преглед</string>
|
||||
<string name="repo_scan_qr_code">Сканиране на QR</string>
|
||||
|
||||
@@ -485,7 +485,6 @@
|
||||
<string name="repo_list_info_title">Llista de repositoris</string>
|
||||
<string name="repo_archive_toggle">Arxiu del repositori</string>
|
||||
<string name="repo_archive_failed">L\'arxiu del dipòsit no està disponible actualment</string>
|
||||
<string name="repo_disable_warning">Desactivant aquest dipòsit traureu el seu estat de \"preferit\" a totes les aplicacions.</string>
|
||||
<plurals name="details_last_update_years">
|
||||
<item quantity="one">Actualitzada fa %1$d any</item>
|
||||
<item quantity="many">Actualitzada fa %1$d anys</item>
|
||||
|
||||
@@ -565,7 +565,6 @@
|
||||
<string name="repo_disable_warning_button">Zakázat</string>
|
||||
<string name="repo_archive_toggle_description">Zobrazit archivované a zastaralé verze aplikací</string>
|
||||
<string name="repo_archive_failed">Archiv repozitáře momentálně není dostupný</string>
|
||||
<string name="repo_disable_warning">Zakázáním tohoto repozitáře jej odeberete z „vybraných“ u všech aplikací.</string>
|
||||
<string name="repo_list_info_text">Repozitář je zdroj aplikací. Tento seznam zobrazuje všechny aktuálně přidané repozitáře. Zakázané repozitáře nejsou používány.\n\nPokud se aplikace nachází více než v jednom repozitáři, bude automaticky vybrán repozitář výše v seznamu. Repozitáře můžete přesunout podržením a přesunutím.</string>
|
||||
<string name="repositories_last_update_never">nikdy</string>
|
||||
<string name="repositories_last_update">Poslední aktualizace: %s</string>
|
||||
|
||||
@@ -545,7 +545,6 @@
|
||||
<string name="repo_invalid">Ugyldigt repository.\n\nKontakt vedligeholderen og informer dem om problemet.</string>
|
||||
<string name="swap_banner_message">Start dit Wi-Fi og forbind til det samme netværk som din ven for at udveksle apps.</string>
|
||||
<string name="repo_list_info_text">Et repository er en kilde til apps. Denne liste viser alle aktuelt tilføjede repositories. Deaktiverede repositories bruges ikke.\n\nHvis en app findes i mere end ét repository, foretrækkes det øverste på listen automatisk. Du kan ændre rækkefølgen af repositories ved at holde og trække dem.</string>
|
||||
<string name="repo_disable_warning">Deaktiverer du dette repository, fjernes dets \"foretrækkes\" status fra alle apps.</string>
|
||||
<string name="copied_to_clipboard">Kopieret til udklipsholder</string>
|
||||
<string name="paste">Indsæt</string>
|
||||
<string name="copied_permission_to_clipboard">Tilladelsesnavn kopieret til udklipsholder</string>
|
||||
|
||||
@@ -562,7 +562,6 @@
|
||||
<string name="repo_archive_toggle_description">Zeig archivierte Apps und veraltete Versionen von Apps</string>
|
||||
<string name="repo_archive_toggle">Paketquellen-Archiv</string>
|
||||
<string name="repo_archive_failed">Archiv-Paketquelle derzeit nicht verfügbar</string>
|
||||
<string name="repo_disable_warning">Das Deaktivieren dieser Paketquelle wird ihren »bevorzugt«-Status von allen Apps entfernen.</string>
|
||||
<string name="repo_list_info_title">Paketquellen-Liste</string>
|
||||
<string name="repo_list_info_text">Eine Paketquelle ist eine Quelle für Apps. Diese Liste zeigt alle derzeit hinzugefügten Paketquellen an. Deaktivierte Paketquellen werden nicht verwendet. \n \nWenn eine App in mehr als einer Paketquelle enthalten ist, wird die in der Liste weiter oben stehende Paketquelle automatisch bevorzugt. Du kannst Paketquellen durch langes Drücken und Ziehen neu anordnen.</string>
|
||||
<string name="repo_error_adding_archive">Archiv-Paketquellen können nicht direkt hinzugefügt werden. Tippe auf die Paketquelle in der Liste und aktiviere das Archiv dort.</string>
|
||||
|
||||
@@ -536,7 +536,6 @@
|
||||
\nΕάν μια εφαρμογή βρίσκεται σε περισσότερα από ένα αποθετήρια, προτιμάται αυτόματα το αποθετήριο που βρίσκεται ψηλότερα στη λίστα. Μπορείτε να αναδιατάξετε τα αποθετήρια πατώντας παρατεταμένα και σύροντάς τα.</string>
|
||||
<string name="repo_archive_toggle">Αρχείο αποθετηρίου</string>
|
||||
<string name="repo_archive_toggle_description">Εμφάνιση αρχειοθετημένων εφαρμογών και ξεπερασμένων εκδόσεων εφαρμογών</string>
|
||||
<string name="repo_disable_warning">Η απενεργοποίηση αυτού του αποθετηρίου θα αφαιρέσει την κατάσταση \"προτιμώμενη\" από όλες τις εφαρμογές.</string>
|
||||
<string name="repo_disable_warning_button">Απενεργοποίηση</string>
|
||||
<string name="copied_url_to_clipboard">Αντιγραφή URL στο πρόχειρο</string>
|
||||
<string name="swap_banner_message">Για να ανταλλάξετε εφαρμογές, ξεκινήστε το Wi-Fi και συνδεθείτε στο ίδιο δίκτυο με τον φίλο σας.</string>
|
||||
|
||||
@@ -561,7 +561,6 @@
|
||||
<string name="repositories_last_update_never">never</string>
|
||||
<string name="repo_list_info_title">Repository list</string>
|
||||
<string name="repo_archive_failed">Archive repo currently not available</string>
|
||||
<string name="repo_disable_warning">Disabling this repository will remove its \"preferred\" status from all apps.</string>
|
||||
<string name="repo_disable_warning_button">Disable</string>
|
||||
<string name="app_details_repositories">Repositories</string>
|
||||
<string name="useDnsCacheSummary">Use cached results to minimise DNS queries.</string>
|
||||
|
||||
@@ -551,7 +551,6 @@
|
||||
<string name="repo_error_adding_archive">Ne eblas senpere aldoni arĥivan deponejon. Frapetu deponejon sur la listo kaj tie aktivigi arĥivon.</string>
|
||||
<string name="repo_archive_failed">Arĥiva deponejo ne estas nune disponebla</string>
|
||||
<string name="repo_disable_warning_button">Malaktivigi</string>
|
||||
<string name="repo_disable_warning">Per malaktivigi tiun ĉi deponejon, ĝi estos forigita kiel preferata deponejo por ĉiuj ĝiaj aplikaĵoj.</string>
|
||||
<string name="repositories_last_update">Lasta ĝisdatigo: %s</string>
|
||||
<string name="repositories_last_update_never">neniam</string>
|
||||
<string name="repo_archive_toggle">Arĥiva deponejo</string>
|
||||
|
||||
@@ -588,7 +588,6 @@
|
||||
<string name="repo_list_info_title">Lista de repositorios</string>
|
||||
<string name="repo_archive_toggle">Versiones históricas</string>
|
||||
<string name="repo_archive_toggle_description">Muestra aplicaciones antiguas y versiones obsoletas de aplicaciones existentes</string>
|
||||
<string name="repo_disable_warning">Deshabilitar este repositorio eliminará su estado \"preferido\" de todas las aplicaciones.</string>
|
||||
<string name="repo_disable_warning_button">Desactivar</string>
|
||||
<string name="repo_list_info_text">Un repositorio es una fuente de aplicaciones. Esta lista muestra todos los repositorios que has añadido. Los repositorios desactivados no se utilizan.\n\nSi una aplicación está en más de un repositorio se utilizará la versión del repositorio que esté más alto en la lista. Puedes reordenar el listado manteniendo pulsado y arrastrando cada entrada.</string>
|
||||
<string name="repo_mirror_add_info">La URL que está intentando añadir (%s) es una réplica de un repositorio existente.</string>
|
||||
|
||||
@@ -567,7 +567,6 @@
|
||||
<string name="repositories_last_update">Viimane uuendus: %s</string>
|
||||
<string name="repo_archive_failed">Arhiiv repo praegu mitte saadaval</string>
|
||||
<string name="repo_archive_toggle_description">Näita arhiivitud äppe või äppide vanasid versioone</string>
|
||||
<string name="repo_disable_warning">Selle tarkvarahoidla väljalülitamine eemaldab tema \"eelistatud\" oleku kõikide rakenduste jaoks.</string>
|
||||
<string name="repo_disable_warning_button">Lülita välja</string>
|
||||
<string name="repositories_last_update_never">mitte kunagi</string>
|
||||
<string name="app_details_repository_button_prefer">Eelista tarkvarahoidlat</string>
|
||||
|
||||
@@ -576,7 +576,6 @@
|
||||
<string name="repo_archive_toggle">Gordailuko artxiboa</string>
|
||||
<string name="repo_archive_toggle_description">Erakutsi gordetako aplikazioak eta aplikazioen bertsio zaharkituak</string>
|
||||
<string name="repo_archive_failed">Artxiboen gordailua une honetan ez dago erabilgarri</string>
|
||||
<string name="repo_disable_warning">Gordailu hau desgaituz gero, aplikazio guztietatik \"hobetsitakoa\" egoera kenduko da.</string>
|
||||
<string name="repo_disable_warning_button">Desgailu</string>
|
||||
<string name="useDnsCache">Erabili DNS cachea</string>
|
||||
<string name="useDnsCacheSummary">Erabili cachean gordetako emaitzak DNS kontsultak minimizatzeko.</string>
|
||||
|
||||
@@ -551,7 +551,6 @@
|
||||
<string name="repo_list_info_title">سیاههٔ مخزنها</string>
|
||||
<string name="repo_archive_toggle">بایگانی مخزن</string>
|
||||
<string name="repo_archive_toggle_description">نمایش کارههای بایگانی شده و نگارشهای از ردهخارجشان</string>
|
||||
<string name="repo_disable_warning">از کار انداختن این مخزن وضعیت «ترجیحی»اش را از همهٔ کارهها برمیدارد.</string>
|
||||
<string name="repo_archive_failed">مخزن بایگانی اکنون در دسترس نیست</string>
|
||||
<string name="app_details_repository">مخزن</string>
|
||||
<string name="app_details_repositories">مخزنها</string>
|
||||
|
||||
@@ -558,7 +558,6 @@
|
||||
<string name="ipfsgw_explainer">Lataa sovelluksia IPFS-verkkopäätepisteistä. (Toimii vain F-Droid-ohjelmavarastoissa, joissa on IPFS-tuki.)</string>
|
||||
<string name="repo_list_info_title">Ohjelmavarastoluettelo</string>
|
||||
<string name="repo_archive_toggle">Ohjelmavarastoarkisto</string>
|
||||
<string name="repo_disable_warning">Tämän ohjelmavaraston poistaminen käytöstä poistaa sen \"ensisijaisen\" tilan kaikista sovelluksista.</string>
|
||||
<string name="ipfsgw_summary_disabled">Lataa sovelluksia IPFS-verkkopäätepisteiden avulla: pois käytöstä</string>
|
||||
<plurals name="ipfsgw_summary">
|
||||
<item quantity="one">Lataa sovelluksia IPFS-verkkopäätepisteen avulla: %1$d käytössä</item>
|
||||
|
||||
@@ -576,7 +576,6 @@
|
||||
<string name="repo_archive_toggle">Archive des dépôts</string>
|
||||
<string name="repo_archive_toggle_description">Afficher les apps archivées et les anciennes versions des apps</string>
|
||||
<string name="repositories_last_update">Dernière mise à jour : %s</string>
|
||||
<string name="repo_disable_warning">Désactiver ce dépôt enlèvera son statut « préféré » de toutes les applications.</string>
|
||||
<string name="repositories_last_update_never">jamais</string>
|
||||
<string name="repo_list_info_text">Un dépôt est un pourvoyeur d\'applis. Cette liste affiche tous les dépôts actuellement ajoutés. Les dépôts désactivés ne sont pas utilisés. \n \nSi une appli est disponible dans plus d\'un dépôt, le dépôt le plus haut dans la liste est préféré. Vous pouvez changer l\'ordre des dépôts avec un appui long puis en les faisant glisser.</string>
|
||||
<string name="repo_error_adding_archive">Les dépôts d\'archive ne peuvent pas être directement ajoutés. Appuyez sur le dépôt dans la liste puis activez l\'archive à cet emplacement.</string>
|
||||
|
||||
@@ -437,7 +437,6 @@
|
||||
<string name="repo_confirm_delete_body">Ciallaíonn scriosadh stór nach mbeidh feidhmchláir uaidh ar fáil a thuilleadh.
|
||||
\n
|
||||
\nNóta: Fanfaidh gach feidhmchlár a suiteáladh roimhe seo ar do ghléas.</string>
|
||||
<string name="repo_disable_warning">Má dhíchumasaítear an stór seo, bainfear a stádas \"is fearr\" as gach feidhmchlár.</string>
|
||||
<string name="repo_disable_warning_button">Díchumasaigh</string>
|
||||
<string name="repo_added">Stór pacáiste sábháilte %1$s.</string>
|
||||
<string name="repo_searching_address">Ag lorg stór pacáiste ag
|
||||
|
||||
@@ -537,7 +537,6 @@
|
||||
<string name="repo_mirror_exists">Este espello xa foi engadido (%s).</string>
|
||||
<string name="repo_mirror_add_info">A URL que está tentando engadir (%s) é un espello dun repositorio existente.</string>
|
||||
<string name="repo_archive_toggle">Arquivo de repositorios</string>
|
||||
<string name="repo_disable_warning">Desactivar este repositorio eliminará o seu estado de \"preferente\" de todas as aplicacións.</string>
|
||||
<string name="repo_disable_warning_button">Desactivar</string>
|
||||
<string tools:ignore="UnusedResources" name="category_Nightly">nocturno</string>
|
||||
<string name="swap_banner_message">Para compartir aplicacións, conéctate á mesma rede Wi-Fi que a túa amizade.</string>
|
||||
|
||||
@@ -585,7 +585,6 @@
|
||||
<string name="repo_list_info_text">מאגר הוא מקור של יישומונים. הרשימה הזאת מציגה את כל המאגרים שנוספו. המאגרים המושבתים אינם בשימוש.
|
||||
\n
|
||||
\nאם יישומון הוא יותר במאגר אחד, המאגר שנמצא יותר גבוה ברשימה מקבל עדיפות אוטומטית. אפשר לסדר את המאגרים מחדש בלחיצה ארוכה עליהם וגרירתם.</string>
|
||||
<string name="repo_disable_warning">השבתת המאגר תסיר את מצב ה„העדפה” שלו מכל היישומונים.</string>
|
||||
<string name="repo_archive_failed">מאגר ארכיון לא זמין כרגע</string>
|
||||
<string name="ipfsgw_summary_disabled">הורדת יישומונים באמצעות נקודות גישת IPFS: מושבתת</string>
|
||||
<string name="ipfsgw_add_title">הוספת שער גישת IPFS</string>
|
||||
|
||||
@@ -605,7 +605,6 @@
|
||||
<string name="repo_archive_failed">Arhivski repozitorij trenutačno nije dostupan</string>
|
||||
<string name="repo_confirm_delete_mirror_title">Izbrisati zrcalni server?</string>
|
||||
<string name="repo_confirm_delete_mirror_body">Možeš nastaviti instalirati aplikacije iz ovog repozitorija pomoću drugih zrcalnih servera.</string>
|
||||
<string name="repo_disable_warning">Deaktiviranje ovog repozitorija će ukloniti njegov „preferirani” status iz svih aplikacija.</string>
|
||||
<string name="repo_disable_warning_button">Deaktiviraj</string>
|
||||
<string name="share_mirror">Dijeli zrcalni server</string>
|
||||
<string name="show_repository_qr">Prikaži QR kod repozitorija</string>
|
||||
|
||||
@@ -570,7 +570,6 @@
|
||||
<string name="repositories_last_update_never">soha</string>
|
||||
<string name="repo_archive_failed">Az archív tároló jelenleg nem érhető el</string>
|
||||
<string name="repo_archive_toggle_description">Archivált alkalmazások és elavult verziók megjelenítése</string>
|
||||
<string name="repo_disable_warning">A tároló letiltásával eltávolítja az „előnyben részesített” állapotát az összes alkalmazástól.</string>
|
||||
<string name="swap_bluetooth_permissions">Adja meg a Bluetooth vagy a Közeli eszköz engedélyt!</string>
|
||||
<string name="auto_update_time_past">Következő frissítés a feltételek teljesülése esetén</string>
|
||||
<string name="menu_share_apk">APK-fájl megosztása</string>
|
||||
|
||||
@@ -538,7 +538,6 @@
|
||||
<string name="repo_share_not_found">Tidak dapat mencari alamat repositori dalam teks terbagi.</string>
|
||||
<string name="repo_archive_toggle">Arsip repositori</string>
|
||||
<string name="repo_archive_failed">Repositori arsip saat ini tidak tersedia</string>
|
||||
<string name="repo_disable_warning">Menonaktifkan repositori ini akan menghilangkan status \"lebih disukai\" dari semua aplikasi.</string>
|
||||
<string name="targetsdk_or_later">sasaran %s</string>
|
||||
<string name="repo_disable_warning_button">Nonaktifkan</string>
|
||||
<string name="ipfsgw_explainer">Unduh aplikasi dari titik akhir web IPFS. (Hanya bekerja pada repositori F-Droid dengan dukungan IPFS.)</string>
|
||||
|
||||
@@ -557,7 +557,6 @@
|
||||
<string name="repo_add_repo_and_mirror">Bæta við hugbúnaðarsafni og spegli</string>
|
||||
<string name="repositories_last_update_never">aldrei</string>
|
||||
<string name="repo_and_mirror_add_both_info">Slóðin sem þú ert að reyna að bæta við (%s) er spegill fyrir nýtt hugbúnaðarsafn. Bætt verður við bæði upprunalegu hugbúnaðarsafni og spegli þess.</string>
|
||||
<string name="repo_disable_warning">Sé þetta hugbúnaðarsafn gert óvirkt verður staða þess sem \"ákjósanlegt\" fjarlægð af öllum forritum.</string>
|
||||
<string name="app_details_repository">Hugbúnaðarsafn</string>
|
||||
<string name="app_details_repository_preferred">(ákjósanlegt)</string>
|
||||
<string name="app_details_repository_expand">Útvíkka lista yfir hugbúnaðarsöfn</string>
|
||||
|
||||
@@ -591,7 +591,6 @@
|
||||
<string name="repositories_last_update_never">mai</string>
|
||||
<string name="repo_list_info_title">Elenco repository</string>
|
||||
<string name="repo_archive_failed">Repo di archivio attualmente non disponibile</string>
|
||||
<string name="repo_disable_warning">Disattivando questo repository verrà rimosso il suo stato \"preferito\" da tutte le app.</string>
|
||||
<string name="repo_add_repo_and_mirror">Aggiungi repository e mirror</string>
|
||||
<string name="repo_view_repo">Vedi il repository</string>
|
||||
<string name="repo_scan_qr_code">Scansiona codice QR</string>
|
||||
|
||||
@@ -559,7 +559,6 @@
|
||||
</plurals>
|
||||
<string name="ipfsgw_summary_disabled">IPFSウェブ終点を使用してアプリをダウンロード: 無効</string>
|
||||
<string name="ipfsgw_explainer">IPFSウェブ終点からアプリをダウンロードします。 (IPFSに対応するF-Droidレポジトリだけで機能します。)</string>
|
||||
<string name="repo_disable_warning">このリポジトリを無効にすると、すべてのアプリからそれの「優先」状態が削除されます。</string>
|
||||
<string name="ipfsgw_title">IPFSゲートウェイ</string>
|
||||
<string name="ipfsgw_add_title">IPFSゲートウェイを追加</string>
|
||||
<string name="nearby_splach__external_storage_permission_explainer">F-DroidはSDカード上からレポジトリとミラーを検索できますが、権限が必要です。</string>
|
||||
|
||||
@@ -501,7 +501,6 @@
|
||||
<string name="swap_wifi_vpn_conflict">ಸಕ್ರಿಯ ವಿ.ಪಿ.ಎನ್ ಜೊತೆಗೆ ಸಂಘರ್ಷಿಸುತ್ತದೆ!</string>
|
||||
<string name="app_name_basic">ಎಫ್-ಡ್ರಾಯ್ಡ್ ಬೇಸಿಕ್</string>
|
||||
<string name="ipfsgw_caption_custom_gateways">ಕಸ್ಟಮೈಜ್ ಐ ಪಿ ಎಫ್ ಎಸ್ ಗೇಟ್ವೇಸ್</string>
|
||||
<string name="repo_disable_warning">ಡಿಸೇಬ್ಲಿಂಗ್ ದಿಸ್ ರೆಪೊಸಿಟರಿ ವಿಲ್ ರಿಮೋವ್ ಇಟ್ಸ್ \"ಪ್ರಿಫರ್ಡ್\" ಸ್ಥಿತಿ ಫ್ರಾಮ್ ಆಲ್ ಅಲ್ ಅಪ್ಪ್ಸ್.</string>
|
||||
<string name="share_apk_error">ಅಪ್ಲಿಕೇಶನ್ ಫೈಲ್ ಹಂಚಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ</string>
|
||||
<string name="warning_target_sdk">ಈ ಅಪ್ಲಿಕೇಶನ್ ಹಳಈ ಅಪ್ಲಿಕೇಶನ್ನು ಹಳೆಯ ಆಂಡ್ರಾಯ್ಡ್ ಆವೃತ್ತಿಗಾಗಿ ನಿರ್ಮಿಸಲಾಗಿದೆ ಮತ್ತು ಸ್ವಯಂಮತ್ತಾಯವಾಗಿ ನವೀಕರಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ</string>
|
||||
<string name="swap_connection_indexv0_error">ನೀವು ಹಳೆಯ ಆವೃತ್ತಿಯ F-Droid ಅನ್ನು ಹೊಂದಿರುವ ಯಾರಾದರು ಸಂಪರ್ಕ ಸ್ಥಾಪಿಸಲು ಪ್ರಯತ್ನಿಸುತ್ತಿದ್ದೀರಿ. ಅವರು ತಮ್ಮ F-Droid ಅಪ್ಲಿಕೇಶನ್ನನ್ನು ನವೀಕರಿಸಬೇಕಾಗಿರುತ್ತದೆ. ಅವರು ನಿಮ್ಮಿಂದ F-Droid ಅಪ್ಲಿಕೇಶನ್ನನ್ನು ನವೀಕರಿಸಲು ಸ್ವಾಪ್ ಬಳಸಬಹುದು.</string>
|
||||
|
||||
@@ -548,7 +548,6 @@
|
||||
<string name="app_list_no_suggested_version">설치에 권장되는 버전이 없습니다.</string>
|
||||
<string name="repo_exists">이 저장소는 이미 추가되었습니다.</string>
|
||||
<string name="repo_error_adding_archive">Archive 저장소는 직접 추가할 수 없습니다. 목록에서 저장소를 탭하고 거기 아카이브를 활성화.</string>
|
||||
<string name="repo_disable_warning">이 저장소를 비활성화하면 모든 앱의 \"preferred\"상태를 제거합니다.</string>
|
||||
<string name="targetsdk_or_later">대상 %s</string>
|
||||
<string name="repositories_last_update">마지막 업데이트: %s</string>
|
||||
<string name="repositories_last_update_never">한 적 없음</string>
|
||||
|
||||
@@ -575,7 +575,6 @@
|
||||
</plurals>
|
||||
<string name="repo_and_mirror_add_both_info">URL, ko tiek mēģināts pievienot (%s), ir jaunas glabātavas spoguļserveris. Tiks pievienota gan sākotnējā glabātava, gan spoguļserveris.</string>
|
||||
<string name="repo_archive_toggle_description">Rādīt arhivētās lietotnes un novecojušas lietotņu versijas</string>
|
||||
<string name="repo_disable_warning">Šīs glabātavas atspējošana noņems tās stāvokli \"vēlama\" no visām lietotnēm.</string>
|
||||
<string name="auto_update_time">Tuvākais nākamais atjauninājums: %s</string>
|
||||
<string name="auto_update_time_past">Nākamais atjauninājums, kad ir izpildījušies nosacījumi</string>
|
||||
<string name="antitetherednet">Piesaistīti tīkla pakalpojumi</string>
|
||||
|
||||
@@ -581,7 +581,6 @@
|
||||
<string name="repo_archive_failed">Pakkebrønnarkivet er ikke tilgjengelig nå</string>
|
||||
<string name="repo_confirm_delete_mirror_title">Slett speilet?</string>
|
||||
<string name="repo_confirm_delete_mirror_body">Du kan fortsette å installere apper fra denne pakkebrønnen ved å bruke andre speil.</string>
|
||||
<string name="repo_disable_warning">Å skru av denne pakkebrønnen vil fjerne den som «foretrukket» fra alle apper.</string>
|
||||
<string name="repo_disable_warning_button">Skru av</string>
|
||||
<string name="share_mirror">Del speil</string>
|
||||
<string name="show_repository_qr">Vis QR-kode til pakkebrønn</string>
|
||||
|
||||
@@ -570,7 +570,6 @@
|
||||
\n
|
||||
\nAls een app in meerdere gegevensbanken voorkomt dan wordt de eerste gegevensbank in de lijst gebruikt. U kunt de volgorde veranderen door een gegevensbank ingedrukt te houden en te verplaatsen.</string>
|
||||
<string name="repo_archive_toggle_description">Toon gearchiveerde apps en verouderde versies van apps</string>
|
||||
<string name="repo_disable_warning">Uitschakelen van deze gegevensbank zal de \"geprefereerde\" status verwijderen van alle apps.</string>
|
||||
<plurals name="ipfsgw_summary">
|
||||
<item quantity="one">Download apps van IPFS web eindpunten: %1$d ingeschakeld</item>
|
||||
<item quantity="other">Download apps van IPFS web eindpunten: %1$d ingeschakeld</item>
|
||||
|
||||
@@ -596,7 +596,6 @@
|
||||
<string name="app_details_repository_button_prefer">Preferuj repozytorium</string>
|
||||
<string name="repo_error_adding_archive">Archiwalne repozytoria nie mogą być dodawane bezpośrednio. Naciśnij repozytorium na liście i włącz tam archiwum.</string>
|
||||
<string name="repositories_last_update">Ostatnia aktualizacja: %s</string>
|
||||
<string name="repo_disable_warning">Wyłączenie tego repozytorium spowoduje usunięcie jego statusu „preferowane” ze wszystkich aplikacji.</string>
|
||||
<string name="repo_add_repo_and_mirror">Dodaj repozytorium i serwer lustrzany</string>
|
||||
<string name="repo_view_repo">Wyświetl repozytorium</string>
|
||||
<string name="repo_scan_qr_code">Zeskanuj kod QR</string>
|
||||
|
||||
@@ -568,7 +568,6 @@
|
||||
<string name="repo_archive_toggle">Arquivo do repositório</string>
|
||||
<string name="repo_archive_toggle_description">Mostrar apps arquivados e versões desatualizadas de apps</string>
|
||||
<string name="repo_archive_failed">O arquivo do repositório não está disponível no momento</string>
|
||||
<string name="repo_disable_warning">Desativar este repositório removerá o estado de \"preferido\" dele de todos os apps.</string>
|
||||
<string name="repo_disable_warning_button">Desativar</string>
|
||||
<string name="app_details_repository_preferred">(preferido)</string>
|
||||
<string name="repo_add_repo_and_mirror">Adicionar repositório e espelho</string>
|
||||
|
||||
@@ -543,7 +543,6 @@
|
||||
<string name="repo_list_info_text">Um repositório é uma fonte de aplicações. Esta lista mostra todos os repositórios atualmente adicionados. Os repositórios desativados não são utilizados.\n\nSe uma aplicação estiver em mais do que um repositório, o repositório no topo da lista é automaticamente preferido. Pode reordenar os repositórios premindo longamente e arrastando-os.</string>
|
||||
<string name="repo_archive_toggle">Arquivo do repositório</string>
|
||||
<string name="repo_archive_failed">O repositório de arquivos não está atualmente disponível</string>
|
||||
<string name="repo_disable_warning">A desativação deste repositório removerá o seu estatdo de \"preferido\" de todas as apps.</string>
|
||||
<string name="repo_disable_warning_button">Desativar</string>
|
||||
<string name="nearby_splach__external_storage_permission_explainer">F-Droid pode procurar por repositórios e espelhos no seu Cartão de Memória, mas precisa da sua permissão para fazer isso.</string>
|
||||
<string name="swap_banner_message">Para trocar aplicações, inicie o seu Wi-Fi e ligue-se à mesma rede que o seu amigo.</string>
|
||||
|
||||
@@ -571,7 +571,6 @@
|
||||
<string name="repo_archive_toggle_description">Mostrar aplicações arquivadas e versões desatualizadas de aplicações</string>
|
||||
<string name="repo_archive_failed">O repositório de arquivos não está atualmente disponível</string>
|
||||
<string name="repo_disable_warning_button">Desativar</string>
|
||||
<string name="repo_disable_warning">A desativação deste repositório removerá o seu estatuto de \"preferido\" de todas as aplicações.</string>
|
||||
<string name="ipfsgw_title">Gateways IPFS</string>
|
||||
<string name="app_details_repository">Repositório</string>
|
||||
<string name="app_details_repositories">Repositórios</string>
|
||||
|
||||
@@ -570,7 +570,6 @@
|
||||
<string name="repo_list_info_title">Lista de depozite</string>
|
||||
<string name="repo_archive_toggle">Arhivă depozit</string>
|
||||
<string name="repo_archive_failed">Arhivă depozit momentan indisponibilă</string>
|
||||
<string name="repo_disable_warning">Dezactivarea acestui depozit îl va elimina ca fiind \"preferat\" pentru toate aplicațiile.</string>
|
||||
<string name="repositories_last_update">Ultima actualizare: %s</string>
|
||||
<string name="repositories_last_update_never">niciodată</string>
|
||||
<string name="repo_list_info_text">Un depozit este o sursă de aplicații. Această listă afișează toate depozitele adăugate în prezent. Depozitele dezactivate nu sunt utilizate.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user