From e906d2f07704b94ab97e308f62ef9f672d269bfb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:50:11 -0400 Subject: [PATCH] feat(launcher): full launcher Apps page (#1686) * feat(launcher): apps page with search, sort, filter, folders, widgets and page swipes * feat(launcher): app shortcuts, pinned shortcuts, hidden apps and LauncherApps listing * fix(ui): let page content scroll to the bottom edge * feat(launcher): accept widgets other apps ask to pin * i18n(launcher): translate apps page strings * fix(launcher): keep disabled pins, serialize widget adds, add many plurals * fix(launcher): drop in-flight pin refreshes after unpinning * fix(launcher): skip unpin when pinned shortcuts can't be read * fix(launcher): drop in-flight app lists when home role is lost * fix(launcher): only migrate folder entries with one launcher activity --- app/src/main/AndroidManifest.xml | 13 + .../droiddeck/launcher/FrontEndActivity.kt | 42 +- .../java/com/droiddeck/launcher/HomeApp.kt | 111 +- .../com/droiddeck/launcher/HomeFolders.kt | 65 ++ .../java/com/droiddeck/launcher/HomeHidden.kt | 26 + .../java/com/droiddeck/launcher/HomePins.kt | 75 ++ .../com/droiddeck/launcher/HomeWidgets.kt | 317 ++++++ .../droiddeck/launcher/PinRequestActivity.kt | 82 ++ .../droiddeck/launcher/ui/AndroidAppsPage.kt | 959 ++++++++++++++++++ .../droiddeck/launcher/ui/ComponentsPage.kt | 4 +- .../droiddeck/launcher/ui/FrontEndContent.kt | 35 +- .../com/droiddeck/launcher/ui/FrontEndRail.kt | 2 +- .../droiddeck/launcher/ui/FrontEndScreen.kt | 12 +- .../com/droiddeck/launcher/ui/LibraryPage.kt | 6 +- .../com/droiddeck/launcher/ui/PageSwipe.kt | 60 ++ .../com/droiddeck/launcher/ui/StorePage.kt | 4 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 2 +- .../com/droiddeck/launcher/ui/UpdatesPage.kt | 2 +- .../launcher/ui/WirelessAdbFixPage.kt | 3 +- app/src/main/res/values-ar/strings.xml | 46 + app/src/main/res/values-b+es+419/strings.xml | 43 + app/src/main/res/values-bg/strings.xml | 42 + app/src/main/res/values-cs/strings.xml | 44 + app/src/main/res/values-da/strings.xml | 42 + app/src/main/res/values-de/strings.xml | 42 + app/src/main/res/values-el/strings.xml | 42 + app/src/main/res/values-es/strings.xml | 43 + app/src/main/res/values-fi/strings.xml | 42 + app/src/main/res/values-fr/strings.xml | 43 + app/src/main/res/values-hi/strings.xml | 42 + app/src/main/res/values-hu/strings.xml | 42 + app/src/main/res/values-in/strings.xml | 41 + app/src/main/res/values-it/strings.xml | 43 + app/src/main/res/values-ja/strings.xml | 41 + app/src/main/res/values-ko/strings.xml | 41 + app/src/main/res/values-ms/strings.xml | 41 + app/src/main/res/values-nb/strings.xml | 42 + app/src/main/res/values-nl/strings.xml | 42 + app/src/main/res/values-pl/strings.xml | 44 + app/src/main/res/values-pt-rPT/strings.xml | 43 + app/src/main/res/values-pt/strings.xml | 43 + app/src/main/res/values-ro/strings.xml | 43 + app/src/main/res/values-ru/strings.xml | 44 + app/src/main/res/values-sv/strings.xml | 42 + app/src/main/res/values-th/strings.xml | 41 + app/src/main/res/values-tr/strings.xml | 42 + app/src/main/res/values-uk/strings.xml | 44 + app/src/main/res/values-vi/strings.xml | 41 + app/src/main/res/values-zh-rCN/strings.xml | 41 + app/src/main/res/values-zh-rHK/strings.xml | 41 + app/src/main/res/values-zh-rTW/strings.xml | 41 + app/src/main/res/values/strings.xml | 42 + 52 files changed, 3160 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/HomeFolders.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/HomeHidden.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/HomePins.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/PinRequestActivity.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AndroidAppsPage.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/PageSwipe.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0f9a8e9c1..806edc12d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,7 @@ + @@ -157,6 +158,18 @@ + + + + + + + (null) private var androidApps by mutableStateOf>(emptyList()) + private var androidAppsListing = 0 + private val packageCallback = object : android.content.pm.LauncherApps.Callback() { + private fun changed() { + refreshAndroidApps() + if (HomeWidgets.providers != null) HomeWidgets.refreshProviders(this@FrontEndActivity) + } + override fun onPackageRemoved(packageName: String, user: android.os.UserHandle) = changed() + override fun onPackageAdded(packageName: String, user: android.os.UserHandle) = changed() + override fun onPackageChanged(packageName: String, user: android.os.UserHandle) = changed() + override fun onPackagesAvailable(packageNames: Array, user: android.os.UserHandle, replacing: Boolean) = changed() + override fun onPackagesUnavailable(packageNames: Array, user: android.os.UserHandle, replacing: Boolean) = changed() + override fun onShortcutsChanged(packageName: String, shortcuts: MutableList, user: android.os.UserHandle) { + if (homeAppSelected) HomePins.refresh(this@FrontEndActivity) + } + } private var secondScreenDisplays by mutableStateOf>(emptyList()) private lateinit var displayManager: DisplayManager private val secondScreenDisplayListener = object : DisplayManager.DisplayListener { @@ -1568,6 +1583,15 @@ class FrontEndActivity : ComponentActivity() { }) displayManager.registerDisplayListener(secondScreenDisplayListener, ui) refreshSecondScreenDisplays() + getSystemService(android.content.pm.LauncherApps::class.java).registerCallback(packageCallback, ui) + HomeWidgets.startListening(this) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (HomeWidgets.onActivityResult(this, requestCode, resultCode)) return + @Suppress("DEPRECATION") + super.onActivityResult(requestCode, resultCode, data) } override fun onSaveInstanceState(outState: Bundle) { @@ -1593,6 +1617,8 @@ class FrontEndActivity : ComponentActivity() { override fun onStop() { unregisterReceiver(wifiLocationReceiver) unregisterReceiver(mediaReceiver) + getSystemService(android.content.pm.LauncherApps::class.java).unregisterCallback(packageCallback) + HomeWidgets.stopListening(this) ui.removeCallbacks(mediaRefresh) com.droiddeck.launcher.stores.StoresState.libraryListener = null com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = null @@ -1608,10 +1634,24 @@ class FrontEndActivity : ComponentActivity() { homeScreenEnabled = HomeApp.isHomeScreenEnabled(this) homeAppSelected = homeScreenEnabled && HomeApp.isDefault(this) defaultHomeLabel = HomeApp.defaultLabel(this) - androidApps = if (homeAppSelected) HomeApp.launchableApps(this) else emptyList() + refreshAndroidApps() integrationState = readIntegration() } + private fun refreshAndroidApps() { + if (!homeAppSelected) { + ++androidAppsListing + androidApps = emptyList() + return + } + val seq = ++androidAppsListing + HomePins.refresh(this) + Thread({ + val apps = runCatching { HomeApp.launchableApps(this) }.getOrDefault(emptyList()) + ui.post { if (seq == androidAppsListing) androidApps = apps } + }, "home-apps").start() + } + private fun refreshSecondScreenDisplays() { if (!::displayManager.isInitialized) return // The launcher can itself be shown on a non-default display (DeX, an external monitor); that one is not a "second screen". diff --git a/app/src/main/java/com/droiddeck/launcher/HomeApp.kt b/app/src/main/java/com/droiddeck/launcher/HomeApp.kt index 819a6525d..9d0ae53c1 100644 --- a/app/src/main/java/com/droiddeck/launcher/HomeApp.kt +++ b/app/src/main/java/com/droiddeck/launcher/HomeApp.kt @@ -6,7 +6,15 @@ import android.graphics.Bitmap import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.LauncherApps +import android.content.pm.ShortcutInfo +import android.graphics.Rect +import android.os.Process +import android.os.UserHandle +import android.os.UserManager import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.provider.Settings import androidx.core.graphics.drawable.toBitmap @@ -22,7 +30,17 @@ object HomeApp { val packageName: String, val className: String, val icon: Bitmap?, - ) + val system: Boolean = false, + val game: Boolean = false, + val installedAt: Long = 0L, + val updatedAt: Long = 0L, + val user: UserHandle? = null, + val userSerial: Long = 0L, + ) { + val component get() = ComponentName(packageName, className) + val key get() = "$packageName/$className" + if (userSerial != 0L) "#$userSerial" else "" + val work get() = userSerial != 0L + } fun isHomeScreenEnabled(context: Context): Boolean = context.packageManager.getComponentEnabledSetting(ComponentName(context, HomeActivity::class.java)) == @@ -72,34 +90,79 @@ object HomeApp { fun settingsIntent(): Intent = Intent(Settings.ACTION_HOME_SETTINGS) fun launchableApps(context: Context): List { + val launcher = context.getSystemService(LauncherApps::class.java) + val users = context.getSystemService(UserManager::class.java) val pm = context.packageManager - val launch = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + val own = Process.myUserHandle() val iconPx = (ICON_DP * context.resources.displayMetrics.density).roundToInt() - val apps = pm.queryIntentActivities(launch, 0) - .asSequence() - .mapNotNull { result -> - val activity = result.activityInfo ?: return@mapNotNull null - if (!activity.exported || activity.packageName == context.packageName) return@mapNotNull null - val label = result.loadLabel(pm)?.toString()?.trim().orEmpty().ifEmpty { activity.packageName } - val icon = runCatching { result.loadIcon(pm).toBitmap(iconPx, iconPx) }.getOrNull() - LaunchableApp(label, activity.packageName, activity.name, icon) + val apps = users.userProfiles.flatMap { user -> + val serial = if (user == own) 0L else users.getSerialNumberForUser(user) + runCatching { launcher.getActivityList(null, user) }.getOrDefault(emptyList()).mapNotNull { info -> + val component = info.componentName + if (component.packageName == context.packageName && user == own) return@mapNotNull null + val label = info.label?.toString()?.trim().orEmpty().ifEmpty { component.packageName } + val icon = runCatching { info.getBadgedIcon(0).toBitmap(iconPx, iconPx) }.getOrNull() + val app = info.applicationInfo + val updated = if (user == own) runCatching { pm.getPackageInfo(component.packageName, 0).lastUpdateTime }.getOrNull() else null + @Suppress("DEPRECATION") + val game = app.category == ApplicationInfo.CATEGORY_GAME || app.flags and ApplicationInfo.FLAG_IS_GAME != 0 + LaunchableApp( + label, component.packageName, component.className, icon, + system = app.flags and (ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0, + game = game, + installedAt = info.firstInstallTime, + updatedAt = updated ?: info.firstInstallTime, + user = user, + userSerial = serial, + ) } - .distinctBy { it.packageName } - .toList() - return apps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }) + } + return apps.distinctBy { it.key }.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }) } - fun launch(context: Context, app: LaunchableApp, displayId: Int? = null) { - val intent = Intent(Intent.ACTION_MAIN) - .addCategory(Intent.CATEGORY_LAUNCHER) - .setComponent(ComponentName(app.packageName, app.className)) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - if (displayId == null) { - context.startActivity(intent) - return - } - val options = ActivityOptions.makeBasic().setLaunchDisplayId(displayId) - context.startActivity(intent, options.toBundle()) + fun launch(context: Context, app: LaunchableApp, displayId: Int? = null, bounds: Rect? = null) { + val options = displayId?.let { ActivityOptions.makeBasic().setLaunchDisplayId(it).toBundle() } + val launcher = context.getSystemService(LauncherApps::class.java) + launcher.startMainActivity(app.component, app.user ?: Process.myUserHandle(), bounds, options) + } + + fun canUseShortcuts(context: Context): Boolean = + runCatching { context.getSystemService(LauncherApps::class.java).hasShortcutHostPermission() }.getOrDefault(false) + + fun shortcuts(context: Context, app: LaunchableApp): List { + if (!canUseShortcuts(context)) return emptyList() + val query = LauncherApps.ShortcutQuery() + .setPackage(app.packageName) + .setActivity(app.component) + .setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST) + return runCatching { context.getSystemService(LauncherApps::class.java).getShortcuts(query, app.user ?: Process.myUserHandle()) } + .getOrNull().orEmpty() + .filter { it.isEnabled } + .sortedWith(compareBy { if (it.isDeclaredInManifest) 0 else 1 }.thenBy { it.rank }) + .take(5) + } + + fun shortcutIcon(context: Context, info: ShortcutInfo, px: Int = (ICON_DP * context.resources.displayMetrics.density).roundToInt()): Bitmap? = runCatching { + context.getSystemService(LauncherApps::class.java) + .getShortcutIconDrawable(info, context.resources.displayMetrics.densityDpi)?.toBitmap(px, px) + }.getOrNull() + + fun startShortcut(context: Context, info: ShortcutInfo) { + context.getSystemService(LauncherApps::class.java).startShortcut(info, null, null) + } + + fun openAppInfo(context: Context, app: LaunchableApp) { + context.getSystemService(LauncherApps::class.java) + .startAppDetailsActivity(app.component, app.user ?: Process.myUserHandle(), null, null) + } + + fun canUninstall(app: LaunchableApp): Boolean = !app.system && !app.work + + fun uninstall(context: Context, app: LaunchableApp) { + context.startActivity( + Intent(Intent.ACTION_DELETE, Uri.fromParts("package", app.packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) } fun isActiveSteamSession(): Boolean = diff --git a/app/src/main/java/com/droiddeck/launcher/HomeFolders.kt b/app/src/main/java/com/droiddeck/launcher/HomeFolders.kt new file mode 100644 index 000000000..dc1f0bca4 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/HomeFolders.kt @@ -0,0 +1,65 @@ +package com.droiddeck.launcher + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID + +object HomeFolders { + data class Folder(val id: String, val name: String, val apps: List) + + private const val PREFS = "home_launcher" + private const val KEY = "folders" + + var folders by mutableStateOf>(emptyList()) + private set + private var loaded = false + + fun load(context: Context) { + if (loaded) return + loaded = true + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null) ?: return + folders = runCatching { + val array = JSONArray(raw) + (0 until array.length()).map { i -> + val o = array.getJSONObject(i) + val apps = o.getJSONArray("apps") + Folder(o.getString("id"), o.getString("name"), (0 until apps.length()).map { apps.getString(it) }) + } + }.getOrDefault(emptyList()) + } + + fun create(context: Context, name: String, appKey: String): Folder { + val folder = Folder(UUID.randomUUID().toString(), name, listOf(appKey)) + update(context, without(appKey) + folder) + return folder + } + + fun add(context: Context, folderId: String, appKey: String) = + update(context, without(appKey).map { if (it.id == folderId) it.copy(apps = it.apps + appKey) else it }) + + fun remove(context: Context, appKey: String) = update(context, without(appKey)) + + fun rename(context: Context, folderId: String, name: String) = + update(context, folders.map { if (it.id == folderId) it.copy(name = name) else it }) + + fun delete(context: Context, folderId: String) = update(context, folders.filterNot { it.id == folderId }) + + fun migrateMembers(context: Context, keyFor: (String) -> String?) { + if (folders.none { f -> f.apps.any { '/' !in it } }) return + update(context, folders.map { f -> f.copy(apps = f.apps.map { if ('/' in it) it else keyFor(it) ?: it }.distinct()) }) + } + + private fun without(appKey: String): List = + folders.map { it.copy(apps = it.apps - appKey) }.filter { it.apps.isNotEmpty() } + + private fun update(context: Context, next: List) { + folders = next + val array = JSONArray() + for (f in next) array.put(JSONObject().put("id", f.id).put("name", f.name).put("apps", JSONArray(f.apps))) + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, array.toString()).apply() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/HomeHidden.kt b/app/src/main/java/com/droiddeck/launcher/HomeHidden.kt new file mode 100644 index 000000000..70f98f484 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/HomeHidden.kt @@ -0,0 +1,26 @@ +package com.droiddeck.launcher + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +object HomeHidden { + private const val PREFS = "home_launcher" + private const val KEY = "hidden" + + var keys by mutableStateOf>(emptySet()) + private set + private var loaded = false + + fun load(context: Context) { + if (loaded) return + loaded = true + keys = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getStringSet(KEY, emptySet()).orEmpty().toSet() + } + + fun set(context: Context, key: String, hidden: Boolean) { + keys = if (hidden) keys + key else keys - key + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putStringSet(KEY, keys).apply() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/HomePins.kt b/app/src/main/java/com/droiddeck/launcher/HomePins.kt new file mode 100644 index 000000000..1040995e8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/HomePins.kt @@ -0,0 +1,75 @@ +package com.droiddeck.launcher + +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.ShortcutInfo +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.os.UserManager +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** Shortcuts other apps asked to put on the home screen. The system keeps the pinned set per launcher. */ +object HomePins { + class Pinned(val info: ShortcutInfo, val icon: Bitmap?, val appLabel: String, val userSerial: Long) { + val key get() = "pin:${info.`package`}/${info.id}" + if (userSerial != 0L) "#$userSerial" else "" + val label get() = info.shortLabel?.toString()?.trim().orEmpty().ifEmpty { appLabel } + val work get() = userSerial != 0L + } + + var pinned by mutableStateOf>(emptyList()) + private set + private val main = Handler(Looper.getMainLooper()) + private var listing = 0 + + fun refresh(context: Context) { + val app = context.applicationContext + val seq = ++listing + Thread({ + val list = runCatching { load(app) }.getOrDefault(emptyList()) + main.post { if (seq == listing) pinned = list } + }, "home-pins").start() + } + + private fun load(context: Context): List { + if (!HomeApp.canUseShortcuts(context)) return emptyList() + val launcher = context.getSystemService(LauncherApps::class.java) + val users = context.getSystemService(UserManager::class.java) + val pm = context.packageManager + val own = Process.myUserHandle() + val query = LauncherApps.ShortcutQuery().setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED) + return users.userProfiles.flatMap { user -> + val serial = if (user == own) 0L else users.getSerialNumberForUser(user) + runCatching { launcher.getShortcuts(query, user) }.getOrNull().orEmpty() + .filter { it.isEnabled && it.isPinned } + .map { info -> + val appLabel = runCatching { pm.getApplicationLabel(pm.getApplicationInfo(info.`package`, 0)).toString() } + .getOrDefault(info.`package`) + Pinned(info, HomeApp.shortcutIcon(context, info), appLabel, serial) + } + } + } + + fun accept(context: Context, request: LauncherApps.PinItemRequest): Boolean { + val ok = runCatching { request.isValid && request.accept() }.getOrDefault(false) + if (ok) refresh(context) + return ok + } + + fun unpin(context: Context, pinned: Pinned) { + val info = pinned.info + val launcher = context.getSystemService(LauncherApps::class.java) + val query = LauncherApps.ShortcutQuery().setPackage(info.`package`).setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED) + val unpinned = runCatching { + val current = checkNotNull(launcher.getShortcuts(query, info.userHandle)) + val keep = current.map { it.id } - info.id + launcher.pinShortcuts(info.`package`, keep, info.userHandle) + }.isSuccess + if (!unpinned) return + listing++ + this.pinned = this.pinned.filterNot { it.key == pinned.key } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt b/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt new file mode 100644 index 000000000..8f79f4354 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt @@ -0,0 +1,317 @@ +package com.droiddeck.launcher + +import android.app.Activity +import android.appwidget.AppWidgetHost +import android.appwidget.AppWidgetHostView +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProviderInfo +import android.content.Context +import android.content.Intent +import android.content.pm.LauncherApps +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.SizeF +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.graphics.drawable.toBitmap +import org.json.JSONArray +import org.json.JSONObject +import kotlin.math.hypot +import kotlin.math.roundToInt + +object HomeWidgets { + data class Placed(val id: Int, val widthDp: Int, val heightDp: Int) + + data class Provider( + val info: AppWidgetProviderInfo, + val label: String, + val appLabel: String, + val preview: Bitmap?, + val widthDp: Int, + val heightDp: Int, + ) + + private const val HOST_ID = 0x0DDE + private const val PREFS = "home_launcher" + private const val KEY = "widgets" + private const val REQUEST_BIND = 0x0DD1 + private const val REQUEST_CONFIGURE = 0x0DD2 + private const val REQUEST_RECONFIGURE = 0x0DD3 + const val MIN_WIDTH_DP = 96 + const val MIN_HEIGHT_DP = 56 + + var widgets by mutableStateOf>(emptyList()) + private set + var providers by mutableStateOf?>(null) + private set + private var loaded = false + private var host: Host? = null + private var pending: AppWidgetProviderInfo? = null + private var pendingId = AppWidgetManager.INVALID_APPWIDGET_ID + private var pinRequest: LauncherApps.PinItemRequest? = null + private val main = Handler(Looper.getMainLooper()) + + private class Host(context: Context) : AppWidgetHost(context, HOST_ID) { + override fun onCreateView(context: Context, appWidgetId: Int, appWidget: AppWidgetProviderInfo?): AppWidgetHostView = + HomeWidgetView(context) + } + + class HomeWidgetView(context: Context) : AppWidgetHostView(context) { + var onLongPress: (() -> Unit)? = null + private var fired = false + private var downX = 0f + private var downY = 0f + private val slop = ViewConfiguration.get(context).scaledTouchSlop + private val longPress = Runnable { + fired = true + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + onLongPress?.invoke() + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + fired = false + downX = ev.x + downY = ev.y + postDelayed(longPress, ViewConfiguration.getLongPressTimeout().toLong()) + } + MotionEvent.ACTION_MOVE -> if (hypot(ev.x - downX, ev.y - downY) > slop) removeCallbacks(longPress) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> removeCallbacks(longPress) + } + return fired + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (fired) { + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) fired = false + return true + } + return super.onTouchEvent(event) + } + } + + private fun host(context: Context): Host = host ?: Host(context.applicationContext).also { host = it } + + fun load(context: Context) { + if (loaded) return + loaded = true + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null) ?: return + val manager = AppWidgetManager.getInstance(context) + val parsed = runCatching { + val array = JSONArray(raw) + (0 until array.length()).map { i -> + val o = array.getJSONObject(i) + Placed(o.getInt("id"), o.getInt("w"), o.getInt("h")) + } + }.getOrDefault(emptyList()) + val live = parsed.filter { manager.getAppWidgetInfo(it.id) != null } + parsed.filterNot { it in live }.forEach { host(context).deleteAppWidgetId(it.id) } + widgets = live + if (live.size != parsed.size) save(context) + } + + fun startListening(context: Context) { + load(context) + runCatching { host(context).startListening() } + } + + fun stopListening(context: Context) { + runCatching { host(context).stopListening() } + } + + fun info(context: Context, id: Int): AppWidgetProviderInfo? = AppWidgetManager.getInstance(context).getAppWidgetInfo(id) + + fun createView(context: Context, id: Int): HomeWidgetView? { + val info = info(context, id) ?: return null + return host(context).createView(context, id, info) as? HomeWidgetView + } + + fun applySize(view: AppWidgetHostView, widthDp: Int, heightDp: Int) { + if (Build.VERSION.SDK_INT >= 31) { + view.updateAppWidgetSize(Bundle(), listOf(SizeF(widthDp.toFloat(), heightDp.toFloat()))) + } else { + @Suppress("DEPRECATION") + view.updateAppWidgetSize(null, widthDp, heightDp, widthDp, heightDp) + } + } + + fun loadProviders(context: Context) { + if (providers != null) return + val app = context.applicationContext + Thread({ + val pm = app.packageManager + val manager = AppWidgetManager.getInstance(app) + val dpi = app.resources.displayMetrics.densityDpi + val list = manager.installedProviders.mapNotNull { info -> + runCatching { + val appLabel = runCatching { pm.getApplicationLabel(pm.getApplicationInfo(info.provider.packageName, 0)).toString() } + .getOrDefault(info.provider.packageName) + val drawable = info.loadPreviewImage(app, dpi) ?: info.loadIcon(app, dpi) + val preview = drawable?.let { d -> + val w = d.intrinsicWidth.takeIf { it > 0 } ?: 192 + val h = d.intrinsicHeight.takeIf { it > 0 } ?: 192 + val scale = minOf(1f, 360f / maxOf(w, h)) + d.toBitmap((w * scale).roundToInt().coerceAtLeast(1), (h * scale).roundToInt().coerceAtLeast(1)) + } + val (w, h) = defaultSize(app, info) + Provider(info, info.loadLabel(pm)?.toString()?.trim().orEmpty().ifEmpty { appLabel }, appLabel, preview, w, h) + }.getOrNull() + }.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, Provider::appLabel).thenBy(String.CASE_INSENSITIVE_ORDER, Provider::label)) + main.post { providers = list } + }, "widget-providers").start() + } + + fun refreshProviders(context: Context) { + providers = null + loadProviders(context) + } + + private fun defaultSize(context: Context, info: AppWidgetProviderInfo): Pair { + val density = context.resources.displayMetrics.density + var w = (info.minWidth / density).roundToInt() + var h = (info.minHeight / density).roundToInt() + if (Build.VERSION.SDK_INT >= 31) { + if (info.targetCellWidth > 0) w = maxOf(w, info.targetCellWidth * 80) + if (info.targetCellHeight > 0) h = maxOf(h, info.targetCellHeight * 80) + } + return w.coerceIn(160, 560) to h.coerceIn(MIN_HEIGHT_DP, 360) + } + + fun minSize(context: Context, id: Int): Pair { + val info = info(context, id) ?: return MIN_WIDTH_DP to MIN_HEIGHT_DP + val density = context.resources.displayMetrics.density + val w = (if (info.minResizeWidth > 0) info.minResizeWidth else info.minWidth) / density + val h = (if (info.minResizeHeight > 0) info.minResizeHeight else info.minHeight) / density + return w.roundToInt().coerceAtLeast(MIN_WIDTH_DP) to h.roundToInt().coerceAtLeast(MIN_HEIGHT_DP) + } + + fun add(activity: Activity, provider: Provider) { + if (pendingId == AppWidgetManager.INVALID_APPWIDGET_ID) add(activity, provider.info, null) + } + + /** Places a widget another app asked for; its own app has already configured it. */ + fun addPinned(activity: Activity, request: LauncherApps.PinItemRequest): Boolean { + if (pendingId != AppWidgetManager.INVALID_APPWIDGET_ID) return false + val info = request.getAppWidgetProviderInfo(activity) ?: return false + load(activity) + add(activity, info, request) + return pending != null + } + + private fun add(activity: Activity, info: AppWidgetProviderInfo, request: LauncherApps.PinItemRequest?) { + val h = host(activity) + val id = h.allocateAppWidgetId() + pending = info + pendingId = id + pinRequest = request + val manager = AppWidgetManager.getInstance(activity) + val bound = runCatching { manager.bindAppWidgetIdIfAllowed(id, info.profile, info.provider, null) }.getOrDefault(false) + if (bound) { + bound(activity, id, info) + return + } + val bind = Intent(AppWidgetManager.ACTION_APPWIDGET_BIND) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER, info.provider) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER_PROFILE, info.profile) + @Suppress("DEPRECATION") + runCatching { activity.startActivityForResult(bind, REQUEST_BIND) }.onFailure { abandon(activity) } + } + + private fun bound(activity: Activity, id: Int, info: AppWidgetProviderInfo) { + val request = pinRequest + if (request == null) { + configure(activity, id, info) + return + } + val accepted = runCatching { request.isValid && request.accept(Bundle().apply { putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, id) }) } + .getOrDefault(false) + if (accepted) place(activity, id, info) else abandon(activity) + } + + private fun configure(activity: Activity, id: Int, info: AppWidgetProviderInfo) { + val optional = Build.VERSION.SDK_INT >= 31 && + info.widgetFeatures and AppWidgetProviderInfo.WIDGET_FEATURE_CONFIGURATION_OPTIONAL != 0 + if (info.configure == null || optional) { + place(activity, id, info) + return + } + runCatching { host(activity).startAppWidgetConfigureActivityForResult(activity, id, 0, REQUEST_CONFIGURE, null) } + .onFailure { place(activity, id, info) } + } + + fun canReconfigure(context: Context, id: Int): Boolean { + val info = info(context, id) ?: return false + if (info.configure == null) return false + return Build.VERSION.SDK_INT < 28 || info.widgetFeatures and AppWidgetProviderInfo.WIDGET_FEATURE_RECONFIGURABLE != 0 + } + + fun reconfigure(activity: Activity, id: Int) { + runCatching { host(activity).startAppWidgetConfigureActivityForResult(activity, id, 0, REQUEST_RECONFIGURE, null) } + } + + fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int): Boolean { + when (requestCode) { + REQUEST_BIND -> { + val info = pending + if (resultCode == Activity.RESULT_OK && info != null) bound(activity, pendingId, info) else abandon(activity) + } + REQUEST_CONFIGURE -> { + val info = pending + if (resultCode == Activity.RESULT_OK && info != null) place(activity, pendingId, info) else abandon(activity) + } + REQUEST_RECONFIGURE -> Unit + else -> return false + } + return true + } + + private fun abandon(context: Context) { + if (pendingId != AppWidgetManager.INVALID_APPWIDGET_ID) host(context).deleteAppWidgetId(pendingId) + pending = null + pinRequest = null + pendingId = AppWidgetManager.INVALID_APPWIDGET_ID + } + + private fun place(context: Context, id: Int, info: AppWidgetProviderInfo) { + val (w, h) = defaultSize(context, info) + pending = null + pinRequest = null + pendingId = AppWidgetManager.INVALID_APPWIDGET_ID + widgets = widgets + Placed(id, w, h) + save(context) + } + + fun resize(context: Context, id: Int, widthDp: Int, heightDp: Int) { + widgets = widgets.map { if (it.id == id) it.copy(widthDp = widthDp, heightDp = heightDp) else it } + save(context) + } + + fun move(context: Context, id: Int, by: Int) { + val i = widgets.indexOfFirst { it.id == id } + val j = i + by + if (i < 0 || j !in widgets.indices) return + widgets = widgets.toMutableList().apply { add(j, removeAt(i)) } + save(context) + } + + fun remove(context: Context, id: Int) { + host(context).deleteAppWidgetId(id) + widgets = widgets.filterNot { it.id == id } + save(context) + } + + private fun save(context: Context) { + val array = JSONArray() + for (w in widgets) array.put(JSONObject().put("id", w.id).put("w", w.widthDp).put("h", w.heightDp)) + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, array.toString()).apply() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/PinRequestActivity.kt b/app/src/main/java/com/droiddeck/launcher/PinRequestActivity.kt new file mode 100644 index 000000000..ca8d382fe --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/PinRequestActivity.kt @@ -0,0 +1,82 @@ +package com.droiddeck.launcher + +import android.content.Intent +import android.content.pm.LauncherApps +import android.graphics.Bitmap +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.graphics.drawable.toBitmap +import com.droiddeck.launcher.core.AppUiPrefs +import com.droiddeck.launcher.ui.DroidDeckTheme +import com.droiddeck.launcher.ui.PinRequestPrompt + +class PinRequestActivity : ComponentActivity() { + override fun attachBaseContext(newBase: android.content.Context) { + super.attachBaseContext(newBase) + com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val request = runCatching { getSystemService(LauncherApps::class.java).getPinItemRequest(intent) }.getOrNull() + if (request == null || !request.isValid) { + finish() + return + } + val prompt = when (request.requestType) { + LauncherApps.PinItemRequest.REQUEST_TYPE_SHORTCUT -> request.shortcutInfo?.let { info -> + val appLabel = appLabel(info.`package`) + Triple(info.shortLabel?.toString().orEmpty().ifEmpty { appLabel }, appLabel, HomeApp.shortcutIcon(this, info)) + } + LauncherApps.PinItemRequest.REQUEST_TYPE_APPWIDGET -> request.getAppWidgetProviderInfo(this)?.let { info -> + val appLabel = appLabel(info.provider.packageName) + val dpi = resources.displayMetrics.densityDpi + val icon = runCatching { (info.loadPreviewImage(this, dpi) ?: info.loadIcon(this, dpi))?.let(::scaled) }.getOrNull() + Triple(info.loadLabel(packageManager)?.toString().orEmpty().ifEmpty { appLabel }, appLabel, icon) + } + else -> null + } + if (prompt == null) { + finish() + return + } + val (label, appLabel, icon) = prompt + val widget = request.requestType == LauncherApps.PinItemRequest.REQUEST_TYPE_APPWIDGET + setContent { + DroidDeckTheme(appScale = AppUiPrefs.scale(this)) { + PinRequestPrompt( + label = label, appLabel = appLabel, icon = icon, wide = widget, + onAdd = { + if (!widget) { + HomePins.accept(this, request) + finish() + } else if (!HomeWidgets.addPinned(this, request)) finish() + }, + onCancel = { finish() }, + ) + } + } + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (HomeWidgets.onActivityResult(this, requestCode, resultCode)) { + finish() + return + } + @Suppress("DEPRECATION") + super.onActivityResult(requestCode, resultCode, data) + } + + private fun appLabel(packageName: String): String = + runCatching { packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString() } + .getOrDefault(packageName) + + private fun scaled(d: android.graphics.drawable.Drawable): Bitmap { + val w = d.intrinsicWidth.takeIf { it > 0 } ?: 192 + val h = d.intrinsicHeight.takeIf { it > 0 } ?: 192 + val k = minOf(1f, 480f / maxOf(w, h)) + return d.toBitmap((w * k).toInt().coerceAtLeast(1), (h * k).toInt().coerceAtLeast(1)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AndroidAppsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/AndroidAppsPage.kt new file mode 100644 index 000000000..fdd0fdd8c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/AndroidAppsPage.kt @@ -0,0 +1,959 @@ +package com.droiddeck.launcher.ui + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.Sort +import androidx.compose.material.icons.filled.Widgets +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import com.droiddeck.launcher.HomeApp +import com.droiddeck.launcher.HomeFolders +import com.droiddeck.launcher.HomeHidden +import com.droiddeck.launcher.HomePins +import android.content.pm.ShortcutInfo +import android.graphics.Bitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.droiddeck.launcher.HomeWidgets +import com.droiddeck.launcher.R +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +private sealed interface AppEntry : AppEntryKey { + val key: String + + class App(val app: HomeApp.LaunchableApp) : AppEntry { + override val label get() = app.label + override val key get() = "app:" + app.key + override val installedAt get() = app.installedAt + override val updatedAt get() = app.updatedAt + } + + class Folder(val folder: HomeFolders.Folder, val apps: List) : AppEntry { + override val label get() = folder.name + override val key get() = "folder:" + folder.id + override val installedAt get() = apps.maxOf { it.installedAt } + override val updatedAt get() = apps.maxOf { it.updatedAt } + } + + class Shortcut(val pinned: HomePins.Pinned) : AppEntry { + override val label get() = pinned.label + override val key get() = pinned.key + override val installedAt get() = 0L + override val updatedAt get() = 0L + } +} + +private fun indexLetter(label: String): String { + val c = label.trim().firstOrNull()?.uppercaseChar() ?: return "#" + return if (c in 'A'..'Z') c.toString() else if (c.isLetter()) c.toString() else "#" +} + +private fun searchRank(label: String, q: String): Int? { + if (label.startsWith(q, ignoreCase = true)) return 0 + if (label.split(' ', '-', '_', '.').any { it.startsWith(q, ignoreCase = true) }) return 1 + if (label.contains(q, ignoreCase = true)) return 2 + return null +} + +internal fun Context.findActivity(): Activity? { + var c: Context? = this + while (c is ContextWrapper) { + if (c is Activity) return c + c = c.baseContext + } + return null +} + +private fun Modifier.menuKey(onMenu: () -> Unit): Modifier = onPreviewKeyEvent { event -> + val k = event.nativeKeyEvent + if (k.keyCode != KeyEvent.KEYCODE_BUTTON_Y && k.keyCode != KeyEvent.KEYCODE_MENU) false + else { + if (k.action == KeyEvent.ACTION_DOWN && k.repeatCount == 0) onMenu() + true + } +} + +internal object AppsView { + const val ALL = "all" + const val GAMES = "games" + const val DOWNLOADED = "downloaded" + const val SYSTEM = "system" + const val HIDDEN = "hidden" + val FILTERS = listOf(ALL, GAMES, DOWNLOADED, SYSTEM, HIDDEN) + const val NAME_ASC = "nameAsc" + const val NAME_DESC = "nameDesc" + const val INSTALLED = "installed" + const val UPDATED = "updated" + val SORTS = listOf(NAME_ASC, NAME_DESC, INSTALLED, UPDATED) + + var filter by mutableStateOf(ALL) + var sort by mutableStateOf(NAME_ASC) + private var loaded = false + + private fun prefs(context: Context) = context.getSharedPreferences("home_launcher", Context.MODE_PRIVATE) + + fun load(context: Context) { + if (loaded) return + loaded = true + val p = prefs(context) + filter = p.getString("filter", ALL)?.takeIf { it in FILTERS } ?: ALL + sort = p.getString("sort", NAME_ASC)?.takeIf { it in SORTS } ?: NAME_ASC + } + + fun setFilter(context: Context, value: String) { + filter = value + prefs(context).edit().putString("filter", value).apply() + } + + fun setSort(context: Context, value: String) { + sort = value + prefs(context).edit().putString("sort", value).apply() + } + + fun keeps(app: HomeApp.LaunchableApp): Boolean { + val hidden = app.key in HomeHidden.keys + return when (filter) { + HIDDEN -> hidden + GAMES -> !hidden && app.game + DOWNLOADED -> !hidden && !app.system + SYSTEM -> !hidden && app.system + else -> !hidden + } + } + + fun comparator(): Comparator = when (sort) { + NAME_DESC -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }.reversed() + INSTALLED -> compareByDescending { it.installedAt }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.label } + UPDATED -> compareByDescending { it.updatedAt }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.label } + else -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.label } + } +} + +internal interface AppEntryKey { + val label: String + val installedAt: Long + val updatedAt: Long +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun AndroidAppsPage(s: FrontEndState, modifier: Modifier, onLaunch: (HomeApp.LaunchableApp) -> Unit) { + val ctx = LocalContext.current + remember { HomeFolders.load(ctx); HomeWidgets.load(ctx); HomeHidden.load(ctx); AppsView.load(ctx); true } + val colors = MaterialTheme.colorScheme + val narrow = LocalNarrowPane.current + val padH = if (narrow) 16.dp else 22.dp + var query by rememberSaveable { mutableStateOf("") } + var openFolder by rememberSaveable { mutableStateOf(null) } + var renameOnOpen by rememberSaveable { mutableStateOf(false) } + var picking by rememberSaveable { mutableStateOf(false) } + val folders = HomeFolders.folders + val byKey = remember(s.androidApps) { s.androidApps.associateBy { it.key } } + val byPkg = remember(s.androidApps) { s.androidApps.filterNot { it.work }.groupBy { it.packageName }.mapNotNull { (pkg, apps) -> apps.singleOrNull()?.let { pkg to it } }.toMap() } + LaunchedEffect(byPkg) { if (byPkg.isNotEmpty()) HomeFolders.migrateMembers(ctx) { byPkg[it]?.key } } + val member: (String) -> HomeApp.LaunchableApp? = { byKey[it] ?: byPkg[it] } + val pins = HomePins.pinned + val hidden = HomeHidden.keys + val q = query.trim() + val filter = AppsView.filter + val sort = AppsView.sort + val entries: List = remember(s.androidApps, folders, q, filter, sort, pins, hidden) { + val kept = s.androidApps.filter { AppsView.keeps(it) } + val shortcuts = if (filter == AppsView.ALL) pins.map { AppEntry.Shortcut(it) } else emptyList() + if (q.isNotEmpty()) { + (kept.map { AppEntry.App(it) } + shortcuts).mapNotNull { e -> searchRank(e.label, q)?.let { it to e } } + .sortedWith(compareBy> { it.first }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.second.label }) + .map { it.second } + } else if (filter != AppsView.ALL) { + kept.map { AppEntry.App(it) }.sortedWith(AppsView.comparator()) + } else { + val folderEntries = folders.mapNotNull { f -> + val apps = f.apps.mapNotNull(member).filter { it.key !in hidden }.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }) + if (apps.isEmpty()) null else AppEntry.Folder(f, apps) + } + val grouped = folderEntries.flatMap { f -> f.apps.map { it.key } }.toSet() + (folderEntries + kept.filter { it.key !in grouped }.map { AppEntry.App(it) } + shortcuts).sortedWith(AppsView.comparator()) + } + } + val grid = rememberLazyGridState() + LaunchedEffect(q, filter, sort) { grid.scrollToItem(0) } + val widgetsShown = q.isEmpty() && filter == AppsView.ALL && HomeWidgets.widgets.isNotEmpty() + val leadItems = if (widgetsShown) 1 else 0 + val byName = sort == AppsView.NAME_ASC || sort == AppsView.NAME_DESC + val newFolder: (HomeApp.LaunchableApp) -> Unit = { app -> + val f = HomeFolders.create(ctx, ctx.getString(R.string.apps_folder_default), app.key) + renameOnOpen = true + openFolder = f.id + } + + Box(modifier) { + Column(Modifier.fillMaxSize().imePadding()) { + Box(Modifier.fillMaxWidth().padding(start = padH, end = padH, top = if (narrow) 8.dp else 12.dp)) { + ModeHeader( + stringResource(R.string.content_android_apps), query, { query = it }, + placeholder = stringResource(R.string.apps_search_hint), + clearSearchFocus = openFolder != null || picking, + lead = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 2.dp, end = 4.dp)) { + Text(stringResource(R.string.content_android_apps), fontSize = if (narrow) 20.sp else 24.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) + val shownCount = s.androidApps.count { AppsView.keeps(it) } + if (filter == AppsView.ALL) pins.size else 0 + Text(androidx.compose.ui.res.pluralStringResource(R.plurals.apps_count, shownCount, shownCount), fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1) + } + }, + ) { + AppsFilterButton() + AppsSortButton() + HeaderIconButton(stringResource(R.string.apps_add_widget), Icons.Filled.Widgets, "add-widget") { + HomeWidgets.loadProviders(ctx) + picking = true + } + } + } + Box(Modifier.weight(1f).fillMaxWidth()) { + val scroller = q.isEmpty() && byName && entries.size > 16 + LazyVerticalGrid( + columns = GridCells.Adaptive(if (narrow) 84.dp else 96.dp), + state = grid, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(start = padH, end = if (scroller) padH + 22.dp else padH, top = 12.dp, bottom = 16.dp), + modifier = Modifier.fillMaxSize(), + ) { + if (widgetsShown) item(key = "widgets", span = { GridItemSpan(maxLineSpan) }) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val maxW = maxWidth + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + ) { + for (w in HomeWidgets.widgets) key(w.id) { WidgetCard(w, maxW.value.toInt()) } + } + } + } + if (entries.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) { + Note( + when { + q.isNotEmpty() -> stringResource(R.string.apps_no_match, q) + filter == AppsView.HIDDEN -> stringResource(R.string.apps_hidden_none) + else -> stringResource(R.string.content_no_android_apps) + }, + ) + } + items(entries, key = { it.key }) { entry -> + when (entry) { + is AppEntry.App -> AppTile( + entry.app, folderId = null, onLaunch = onLaunch, onNewFolder = newFolder, + first = entry === entries.first(), + ) + is AppEntry.Shortcut -> PinnedTile(entry.pinned, first = entry === entries.first()) + is AppEntry.Folder -> FolderTile(entry, first = entry === entries.first(), onOpen = { renameOnOpen = false; openFolder = entry.folder.id }) { + renameOnOpen = true + openFolder = entry.folder.id + } + } + } + } + if (scroller) AlphabetScroller(entries, grid, leadItems, Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(end = 4.dp, top = 8.dp, bottom = 8.dp)) + } + } + + val folder = folders.firstOrNull { it.id == openFolder } + LaunchedEffect(folder == null, openFolder) { if (folder == null && openFolder != null) openFolder = null } + AnimatedVisibility( + folder != null, enter = fadeIn(Motion.tw(180)), exit = fadeOut(Motion.tw(160)), + modifier = Modifier.fillMaxSize().zIndex(2f), + ) { + var last by remember { mutableStateOf(folder) } + if (folder != null) last = folder + last?.let { f -> + FolderSheet(f, f.apps.mapNotNull(member).filter { it.key !in hidden }.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }), renameOnOpen, onLaunch, newFolder) { + openFolder = null + renameOnOpen = false + } + } + } + AnimatedVisibility( + picking, enter = fadeIn(Motion.tw(180)), exit = fadeOut(Motion.tw(160)), + modifier = Modifier.fillMaxSize().zIndex(3f), + ) { WidgetPicker(padH) { picking = false } } + } +} + +@Composable +private fun SwipeLock() { + DisposableEffect(Unit) { + PageSwipe.locks++ + onDispose { PageSwipe.locks-- } + } +} + +@Composable +private fun appsFilterLabel(filter: String): String = stringResource( + when (filter) { + AppsView.GAMES -> R.string.apps_filter_games + AppsView.DOWNLOADED -> R.string.apps_filter_downloaded + AppsView.SYSTEM -> R.string.apps_filter_system + AppsView.HIDDEN -> R.string.apps_filter_hidden + else -> R.string.apps_filter_all + }, +) + +@Composable +private fun appsSortLabel(sort: String): String = stringResource( + when (sort) { + AppsView.NAME_DESC -> R.string.apps_sort_name_desc + AppsView.INSTALLED -> R.string.apps_sort_installed + AppsView.UPDATED -> R.string.apps_sort_updated + else -> R.string.apps_sort_name_asc + }, +) + +@Composable +private fun AppsFilterButton() = HeaderChoiceButton( + stringResource(R.string.apps_filter), Icons.Filled.FilterAlt, "apps-filter", + AppsView.FILTERS, AppsView.filter, { appsFilterLabel(it) }, AppsView::setFilter, +) + +@Composable +private fun AppsSortButton() = HeaderChoiceButton( + stringResource(R.string.stores_sort), Icons.Filled.Sort, "apps-sort", + AppsView.SORTS, AppsView.sort, { appsSortLabel(it) }, AppsView::setSort, +) + +@Composable +private fun HeaderChoiceButton( + title: String, icon: androidx.compose.ui.graphics.vector.ImageVector, key: String, + options: List, current: String, label: @Composable (String) -> String, onPick: (Context, String) -> Unit, +) { + val pal = LocalPalette.current + val ctx = LocalContext.current + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + HeaderIconButton("$title · ${label(current)}", icon, key) { + val from = at ?: return@HeaderIconButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = options.size, + handle = { Icon(icon, null, tint = pal.signal, modifier = Modifier.size(18.dp)) }, + ) { + StepTitle(title) + StepList { + options.forEachIndexed { i, option -> + StepChoice( + label(option), selected = option == current, enabled = open, + modifier = Modifier.fillMaxWidth().stepItem(i).then(if (option == current) Modifier.focusRequester(first) else Modifier), + ) { + onPick(ctx, option) + Steps.fold() + } + } + } + }) + } + } +} + +@Composable +private fun AlphabetScroller(entries: List, grid: LazyGridState, leadItems: Int, modifier: Modifier) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val scope = rememberCoroutineScope() + val firstAt = remember(entries) { + val map = LinkedHashMap() + entries.forEachIndexed { i, e -> map.putIfAbsent(indexLetter(e.label), i) } + map + } + val letters = remember(firstAt) { firstAt.keys.toList() } + var dragging by remember { mutableStateOf(null) } + val current by remember(entries, leadItems) { + derivedStateOf { + val i = (grid.firstVisibleItemIndex - leadItems).coerceIn(0, (entries.size - 1).coerceAtLeast(0)) + entries.getOrNull(i)?.let { indexLetter(it.label) } + } + } + var height by remember { mutableIntStateOf(1) } + val jump: (Float) -> Unit = { y -> + val i = ((y / height) * letters.size).toInt().coerceIn(0, letters.size - 1) + val letter = letters[i] + if (letter != dragging) { + dragging = letter + val at = firstAt[letter] ?: 0 + scope.launch { grid.scrollToItem(if (at == 0) 0 else leadItems + at) } + } + } + Box(modifier.width(22.dp)) { + Column( + verticalArrangement = Arrangement.SpaceEvenly, horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + .clip(RoundedCornerShape(11.dp)) + .background(if (dragging != null) colors.surface.copy(alpha = 0.85f) else Color.Transparent) + .onSizeChanged { height = it.height.coerceAtLeast(1) } + .pointerInput(letters) { + awaitEachGesture { + val down = awaitFirstDown() + down.consume() + jump(down.position.y) + while (true) { + val event = awaitPointerEvent() + val c = event.changes.firstOrNull { it.id == down.id } ?: break + if (!c.pressed) break + c.consume() + jump(c.position.y) + } + dragging = null + } + }, + ) { + for (l in letters) { + val on = l == (dragging ?: current) + Text( + l, fontSize = 10.sp, lineHeight = 11.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.Medium, + color = if (on) pal.signal else colors.onSurfaceVariant, textAlign = TextAlign.Center, + ) + } + } + dragging?.let { l -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.align(Alignment.Center).offset { IntOffset(-64.dp.roundToPx(), 0) }.size(56.dp) + .clip(CircleShape).background(pal.signal), + ) { Text(l, fontSize = 26.sp, fontWeight = FontWeight.Bold, color = pal.onPrimary) } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TileFrame( + id: String, first: Boolean, onClick: () -> Unit, onMenu: () -> Unit, + content: @Composable (hot: Boolean) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.95f else if (hot) 1.04f else 1f, Motion.sp(0.55f, Spring.StiffnessMedium), label = "appScale") + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + .paneItem("tile:$id").then(if (first) Modifier.firstTile().paneEntry() else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(Shape12) + .background(if (hot) colors.surface else colors.surface.copy(alpha = 0.55f)) + .glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp) + .hoverable(src) + .combinedClickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onLongClick = onMenu, onClick = onClick) + .controllerConfirm(onClick = onClick) + .menuKey(onMenu) + .padding(horizontal = 6.dp, vertical = 10.dp), + ) { content(hot) } +} + +@Composable +private fun TileLabel(text: String) = Text( + text, fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), +) + +@Composable +private fun AppIcon(app: HomeApp.LaunchableApp, size: androidx.compose.ui.unit.Dp) { + val icon = app.icon + if (icon != null) Image(icon.asImageBitmap(), null, contentScale = ContentScale.Fit, modifier = Modifier.size(size)) + else Box(Modifier.size(size).clip(CircleShape).background(artBrush(hueOf(app.label)))) +} + +@Composable +private fun AppTile( + app: HomeApp.LaunchableApp, folderId: String?, first: Boolean, + onLaunch: (HomeApp.LaunchableApp) -> Unit, onNewFolder: (HomeApp.LaunchableApp) -> Unit, +) { + var menu by remember { mutableStateOf(false) } + Box { + TileFrame(app.key, first, onClick = { onLaunch(app) }, onMenu = { menu = true }) { + AppIcon(app, 52.dp) + TileLabel(app.label) + } + AppMenu(app, folderId, menu, onDismiss = { menu = false }, onNewFolder = onNewFolder) + } +} + +@Composable +private fun AppMenu( + app: HomeApp.LaunchableApp, folderId: String?, open: Boolean, + onDismiss: () -> Unit, onNewFolder: (HomeApp.LaunchableApp) -> Unit, +) { + val ctx = LocalContext.current + var choosing by remember { mutableStateOf(false) } + var shortcuts by remember { mutableStateOf>>(emptyList()) } + LaunchedEffect(open) { + if (!open) { + choosing = false + return@LaunchedEffect + } + shortcuts = withContext(Dispatchers.IO) { + HomeApp.shortcuts(ctx, app).map { it to HomeApp.shortcutIcon(ctx, it, 64) } + } + } + val isHidden = app.key in HomeHidden.keys + AnchoredMenu(open, onDismiss = onDismiss, title = app.label, compact = true, minWidth = 200.dp) { first -> + if (choosing) { + val others = HomeFolders.folders.filter { it.id != folderId } + var firstUsed = false + for (f in others) key(f.id) { + MenuItem(f.name, checked = false, focusRequester = if (!firstUsed) first.also { firstUsed = true } else null) { + HomeFolders.add(ctx, f.id, app.key) + onDismiss() + } + } + MenuItem(stringResource(R.string.apps_new_folder), checked = false, focusRequester = if (others.isEmpty()) first else null) { + onDismiss() + onNewFolder(app) + } + } else { + shortcuts.forEachIndexed { i, (info, icon) -> + key(info.id) { + MenuItem( + info.shortLabel?.toString().orEmpty(), checked = false, + focusRequester = if (i == 0) first else null, + leading = { MenuIcon(icon) }, + ) { + onDismiss() + runCatching { HomeApp.startShortcut(ctx, info) } + } + } + } + if (shortcuts.isNotEmpty()) Box(Modifier.fillMaxWidth().padding(vertical = 4.dp).height(1.dp).background(LocalPalette.current.line)) + if (!isHidden) MenuItem(stringResource(R.string.apps_add_to_folder), checked = false, focusRequester = if (shortcuts.isEmpty()) first else null) { choosing = true } + if (folderId != null) MenuItem(stringResource(R.string.apps_remove_from_folder), checked = false) { + HomeFolders.remove(ctx, app.key) + onDismiss() + } + MenuItem(stringResource(if (isHidden) R.string.apps_unhide else R.string.apps_hide), checked = false, + focusRequester = if (isHidden && shortcuts.isEmpty()) first else null) { + onDismiss() + HomeHidden.set(ctx, app.key, !isHidden) + } + MenuItem(stringResource(R.string.apps_app_info), checked = false) { + onDismiss() + runCatching { HomeApp.openAppInfo(ctx, app) } + } + if (HomeApp.canUninstall(app)) MenuItem(stringResource(R.string.apps_uninstall), checked = false) { + onDismiss() + runCatching { HomeApp.uninstall(ctx, app) } + } + } + } +} + +@Composable +private fun MenuIcon(icon: Bitmap?) { + if (icon != null) Image(icon.asImageBitmap(), null, modifier = Modifier.size(20.dp)) + else Spacer(Modifier.size(20.dp)) +} + +@Composable +private fun PinnedTile(pinned: HomePins.Pinned, first: Boolean) { + val ctx = LocalContext.current + var menu by remember { mutableStateOf(false) } + Box { + TileFrame(pinned.key, first, onClick = { runCatching { HomeApp.startShortcut(ctx, pinned.info) } }, onMenu = { menu = true }) { + Box { + val icon = pinned.icon + if (icon != null) Image(icon.asImageBitmap(), null, contentScale = ContentScale.Fit, modifier = Modifier.size(52.dp)) + else Box(Modifier.size(52.dp).clip(CircleShape).background(artBrush(hueOf(pinned.label)))) + } + TileLabel(pinned.label) + } + AnchoredMenu(menu, onDismiss = { menu = false }, title = pinned.label, compact = true, minWidth = 180.dp) { firstItem -> + MenuItem(stringResource(R.string.apps_shortcut_remove), checked = false, detail = pinned.appLabel, focusRequester = firstItem) { + menu = false + HomePins.unpin(ctx, pinned) + } + } + } +} + +@Composable +private fun FolderIcon(apps: List, size: androidx.compose.ui.unit.Dp) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val cell = (size - 14.dp) / 2 + Box( + Modifier.size(size).clip(RoundedCornerShape(size / 3.5f)).background(colors.surfaceVariant) + .glideBorder(false, RoundedCornerShape(size / 3.5f), pal.signal, pal.line2) + .padding(5.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + for (row in apps.take(4).chunked(2)) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (a in row) AppIcon(a, cell) + } + } + } +} + +@Composable +private fun FolderTile(entry: AppEntry.Folder, first: Boolean, onOpen: () -> Unit, onRename: () -> Unit) { + val ctx = LocalContext.current + var menu by remember { mutableStateOf(false) } + Box { + TileFrame(entry.key, first, onClick = onOpen, onMenu = { menu = true }) { + FolderIcon(entry.apps, 52.dp) + TileLabel(entry.folder.name) + } + AnchoredMenu(menu, onDismiss = { menu = false }, title = entry.folder.name, compact = true, minWidth = 160.dp) { firstItem -> + MenuItem(stringResource(R.string.apps_folder_rename), checked = false, focusRequester = firstItem) { + menu = false + onRename() + } + MenuItem(stringResource(R.string.apps_folder_ungroup), checked = false) { + menu = false + HomeFolders.delete(ctx, entry.folder.id) + } + } + } +} + +@Composable +private fun FolderSheet( + folder: HomeFolders.Folder, apps: List, renameFirst: Boolean, + onLaunch: (HomeApp.LaunchableApp) -> Unit, onNewFolder: (HomeApp.LaunchableApp) -> Unit, onClose: () -> Unit, +) { + SwipeLock() + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + var renaming by remember(folder.id) { mutableStateOf(renameFirst) } + var name by remember(folder.id) { mutableStateOf(if (renameFirst) "" else folder.name) } + val commit = { + val n = name.trim() + if (n.isNotEmpty() && n != folder.name) HomeFolders.rename(ctx, folder.id, n) + renaming = false + } + val close = { if (renaming) commit(); onClose() } + BackHandler { close() } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.55f)) + .pointerInput(Unit) { awaitEachGesture { val d = awaitFirstDown(); d.consume(); close() } }, + ) { + val shown = remember { androidx.compose.animation.core.MutableTransitionState(false) }.apply { targetState = true } + AnimatedVisibility(shown, enter = scaleIn(Motion.sp(0.75f), initialScale = 0.92f) + fadeIn(Motion.tw(160)), exit = scaleOut(Motion.tw(140)) + fadeOut(Motion.tw(140))) { + Column( + modifier = Modifier.widthIn(max = 620.dp).fillMaxWidth(0.9f).heightIn(max = 520.dp) + .clip(Shape16).background(pal.surfaceVariant.copy(alpha = 0.97f)) + .glideBorder(false, Shape16, pal.signal, pal.signal.copy(alpha = 0.22f)) + .pointerInput(Unit) { awaitEachGesture { awaitFirstDown(requireUnconsumed = false).consume() } } + .controllerBack(close) + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)) { + if (renaming) Box(Modifier.weight(1f)) { + AdbTextField( + value = name, onValueChange = { name = it.take(40) }, label = stringResource(R.string.apps_folder_name), + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done, compact = true, showLabel = false, + placeholder = folder.name, focusOnShow = true, onDone = { commit() }, + ) + } else { + Text(folder.name, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + SmallIconButton(Icons.Filled.Edit, stringResource(R.string.apps_folder_rename), tint = colors.onSurfaceVariant) { renaming = true } + Spacer(Modifier.weight(1f)) + } + SmallIconButton(Icons.Filled.Close, stringResource(R.string.common_close), tint = colors.onSurfaceVariant) { close() } + } + LazyVerticalGrid( + columns = GridCells.Adaptive(92.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(apps, key = { it.key }) { app -> + AppTile(app, folderId = folder.id, first = false, onLaunch = { onClose(); onLaunch(it) }, onNewFolder = onNewFolder) + } + } + } + } + } +} + +@Composable +private fun WidgetCard(w: HomeWidgets.Placed, maxWidthDp: Int) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + var menu by remember { mutableStateOf(false) } + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val width = w.widthDp.coerceAtMost(maxWidthDp) + val activity = remember(ctx) { ctx.findActivity() } + Box { + Box( + Modifier.size(width.dp, w.heightDp.dp) + .clip(Shape16) + .background(colors.surface.copy(alpha = 0.35f)) + .glideBorder(hot || menu, Shape16, pal.signal) + .hoverable(src).focusable(interactionSource = src) + .controllerConfirm { menu = true } + .menuKey { menu = true }, + ) { + AndroidView( + factory = { c -> + (HomeWidgets.createView(c, w.id) ?: android.view.View(c)).apply { + val radius = 16 * c.resources.displayMetrics.density + outlineProvider = object : android.view.ViewOutlineProvider() { + override fun getOutline(view: android.view.View, outline: android.graphics.Outline) = + outline.setRoundRect(0, 0, view.width, view.height, radius) + } + clipToOutline = true + } + }, + update = { v -> + if (v is HomeWidgets.HomeWidgetView) { + v.onLongPress = { menu = true } + HomeWidgets.applySize(v, width, w.heightDp) + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + val (minW, minH) = remember(w.id) { HomeWidgets.minSize(ctx, w.id) } + val index = HomeWidgets.widgets.indexOfFirst { it.id == w.id } + val label = remember(w.id) { HomeWidgets.info(ctx, w.id)?.loadLabel(ctx.packageManager) ?: "" } + AnchoredMenu(menu, onDismiss = { menu = false }, title = label, compact = true, minWidth = 180.dp) { first -> + val step = 48 + MenuItem(stringResource(R.string.apps_widget_wider), checked = false, enabled = width < maxWidthDp, focusRequester = first) { + HomeWidgets.resize(ctx, w.id, (width + step).coerceAtMost(maxWidthDp), w.heightDp) + } + MenuItem(stringResource(R.string.apps_widget_narrower), checked = false, enabled = width > minW) { + HomeWidgets.resize(ctx, w.id, (width - step).coerceAtLeast(minW), w.heightDp) + } + MenuItem(stringResource(R.string.apps_widget_taller), checked = false, enabled = w.heightDp < 480) { + HomeWidgets.resize(ctx, w.id, width, (w.heightDp + step).coerceAtMost(480)) + } + MenuItem(stringResource(R.string.apps_widget_shorter), checked = false, enabled = w.heightDp > minH) { + HomeWidgets.resize(ctx, w.id, width, (w.heightDp - step).coerceAtLeast(minH)) + } + if (index > 0) MenuItem(stringResource(R.string.apps_widget_move_earlier), checked = false) { HomeWidgets.move(ctx, w.id, -1) } + if (index in 0 until HomeWidgets.widgets.size - 1) MenuItem(stringResource(R.string.apps_widget_move_later), checked = false) { HomeWidgets.move(ctx, w.id, 1) } + if (activity != null && HomeWidgets.canReconfigure(ctx, w.id)) MenuItem(stringResource(R.string.apps_widget_configure), checked = false) { + menu = false + HomeWidgets.reconfigure(activity, w.id) + } + MenuItem(stringResource(R.string.apps_widget_remove), checked = false) { + menu = false + HomeWidgets.remove(ctx, w.id) + } + } + } +} + +@Composable +private fun WidgetPicker(padH: androidx.compose.ui.unit.Dp, onClose: () -> Unit) { + SwipeLock() + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val activity = remember(ctx) { ctx.findActivity() } + BackHandler { onClose() } + val providers = HomeWidgets.providers + Column(Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { awaitEachGesture { awaitFirstDown(requireUnconsumed = false) } }) { + LazyVerticalGrid( + columns = GridCells.Adaptive(150.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(horizontal = padH, vertical = 14.dp), + modifier = Modifier.fillMaxSize(), + ) { + item(key = "back", span = { GridItemSpan(maxLineSpan) }) { + Column { + Box(Modifier.padding(bottom = 10.dp)) { BackLink(stringResource(R.string.rail_apps), compact = true, onClick = onClose) } + PageHeader(stringResource(R.string.apps_add_widget)) { + if (providers != null) Chip(providers.size.toString(), ok = false) + } + } + } + when { + providers == null -> item(key = "loading", span = { GridItemSpan(maxLineSpan) }) { Note(stringResource(R.string.apps_widgets_loading)) } + providers.isEmpty() -> item(key = "none", span = { GridItemSpan(maxLineSpan) }) { Note(stringResource(R.string.apps_widgets_none)) } + else -> items(providers, key = { it.info.provider.flattenToString() }) { p -> + ProviderCard(p, first = p === providers.first()) { + onClose() + if (activity != null) HomeWidgets.add(activity, p) + } + } + } + } + } +} + +@Composable +private fun ProviderCard(p: HomeWidgets.Provider, first: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Column( + modifier = Modifier.fillMaxWidth().paneItem("widget:" + p.info.provider.flattenToString()).then(if (first) Modifier.firstTile() else Modifier) + .clip(Shape12).background(colors.surface) + .glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp) + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().aspectRatio(1.6f).background(colors.surfaceVariant).padding(10.dp), + ) { + val preview = p.preview + if (preview != null) Image(preview.asImageBitmap(), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize()) + } + Column(Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) { + Text(p.label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + val cells = "${(p.widthDp / 80f).roundToInt().coerceAtLeast(1)} × ${(p.heightDp / 80f).roundToInt().coerceAtLeast(1)}" + Text(if (p.appLabel == p.label) cells else "${p.appLabel} · $cells", fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +fun PinRequestPrompt(label: String, appLabel: String, icon: Bitmap?, wide: Boolean, onAdd: () -> Unit, onCancel: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + BackHandler { onCancel() } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.55f)) + .pointerInput(Unit) { awaitEachGesture { awaitFirstDown().consume(); onCancel() } }, + ) { + val shown = remember { androidx.compose.animation.core.MutableTransitionState(false) }.apply { targetState = true } + AnimatedVisibility(shown, enter = scaleIn(Motion.sp(0.75f), initialScale = 0.92f) + fadeIn(Motion.tw(160))) { + Column( + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.widthIn(min = 280.dp, max = 420.dp) + .clip(Shape16).background(pal.surfaceVariant.copy(alpha = 0.97f)) + .glideBorder(false, Shape16, pal.signal, pal.signal.copy(alpha = 0.22f)) + .pointerInput(Unit) { awaitEachGesture { awaitFirstDown(requireUnconsumed = false).consume() } } + .controllerBack(onCancel) + .padding(18.dp), + ) { + Text( + stringResource(R.string.apps_pin_title).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, + ) + if (wide && icon != null) Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp).clip(Shape12).background(MaterialTheme.colorScheme.surface).padding(10.dp), + ) { Image(icon.asImageBitmap(), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxWidth()) } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + if (!wide) { + if (icon != null) Image(icon.asImageBitmap(), null, contentScale = ContentScale.Fit, modifier = Modifier.size(52.dp)) + else Box(Modifier.size(52.dp).clip(CircleShape).background(artBrush(hueOf(label)))) + } + Column(Modifier.weight(1f)) { + Text(label, fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(appLabel, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.common_cancel), onClick = onCancel) + PrimaryButton(stringResource(R.string.apps_pin_add), onClick = onAdd) + } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 364650b90..a934d39cf 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -699,7 +699,7 @@ fun ComponentsPage( Box(Modifier.fillMaxSize().onGloballyPositioned { pageAt = it.positionInRoot() }.onFocusChanged { pageFocused = it.hasFocus }) { Column( - Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp) + Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp).padding(top = if (narrow) 12.dp else 18.dp) .then(if (selectedGame == null) Modifier.bumpers( onPrevious = { pick(layerStep(layer, -1)) }, onNext = { pick(layerStep(layer, 1)) }, @@ -972,7 +972,7 @@ private fun Stack( onFocus = { onFocus(l) }, onClick = { onOpen(l) }, ) } - Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { StackHeading(stringResource(R.string.comp_this_device)) card(GPU_TAB) Spacer(Modifier.height(6.dp)) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 419329016..62c6a862e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -10,6 +10,8 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.scaleOut import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.rememberUpdatedState @@ -138,12 +140,19 @@ internal fun Pane( before[0] = target val targetNow by rememberUpdatedState(target) val leave = if (Hops.linking) PageLeave.Link else PageLeave.Ordinary - LaunchedEffect(target) { Hops.linking = false } + LaunchedEffect(target) { + Hops.linking = false + PageSwipe.direction = 0 + } AnimatedContent( targetState = target, modifier = Modifier.onGloballyPositioned { paneAt[0] = it.positionInRoot() }, transitionSpec = { + val swipe = PageSwipe.direction when { + swipe != 0 -> (fadeIn(Motion.tw(260, 40)) + slideInHorizontally(Motion.tw(360)) { it * swipe / 8 }) + .togetherWith(fadeOut(Motion.tw(160)) + slideOutHorizontally(Motion.tw(220)) { -it * swipe / 8 }) + .apply { targetContentZIndex = 1f } targetState in origins -> (fadeIn(Motion.tw(120))) .togetherWith(fadeOut(Motion.tw(320, 80)) + scaleOut(Motion.tw(480), targetScale = 0.95f)) .apply { targetContentZIndex = 1f } @@ -232,13 +241,13 @@ private fun Content( val padH = if (narrow) 16.dp else 22.dp val padV = if (narrow) 12.dp else 18.dp if (selected == "setup") { - Column(modifier = modifier.padding(horizontal = padH, vertical = padV)) { + Column(modifier = modifier.padding(horizontal = padH).padding(top = padV)) { SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb, onPairWirelessDebugging) } return } if (selected == "updates") { - UpdatesPage(s, a, modifier.padding(horizontal = padH, vertical = padV)) + UpdatesPage(s, a, modifier.padding(horizontal = padH).padding(top = padV)) return } if (selected == "steam") { @@ -261,24 +270,12 @@ private fun Content( DesktopPage(s, a, modifier, onSelect) return } + if (selected == "android-apps" && s.isHomeApp) { + AndroidAppsPage(s, modifier, onAndroidAppClick) + return + } Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) { when { - selected == "android-apps" && s.isHomeApp -> { - Rise(0) { PageHeader(stringResource(R.string.content_android_apps)) { Chip(stringResource(R.string.content_apps_count, s.androidApps.size), ok = false) } } - if (s.androidApps.isEmpty()) Rise(3) { Note(stringResource(R.string.content_no_android_apps)) } - else Rise(3, Modifier.fillMaxWidth()) { - ArtGrid(s.androidApps.map { app -> - Tile( - app.label, - null, - null, - "android:${app.packageName}", - onClick = { onAndroidAppClick(app) }, - iconBitmap = app.icon, - ) - }) - } - } selected.startsWith("user:") -> { val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected } if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index 108724000..b7ac8ee43 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -118,7 +118,7 @@ internal fun BottomBar( } } -private fun navigationKeys(s: FrontEndState) = buildList { +internal fun navigationKeys(s: FrontEndState) = buildList { add("steam"); add("games") if (s.gameStoresEnabled) add("stores") add("desktop"); add("components") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 3223826f3..69abceb38 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -507,6 +507,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store" -> "desktop" else -> s.pageKey ?: selected } + val railSelectionNow = androidx.compose.runtime.rememberUpdatedState(railSelection) var railPicks by remember { mutableStateOf(0) } val showRailPage: (String, Boolean) -> Unit = { key, focusContent -> if (key == "components") a.onComponents(focusContent) @@ -609,7 +610,16 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com }, ) { Pane( - s, selected, a, page, Modifier.fillMaxSize().then(paneFocus), { selected = it }, + s, selected, a, page, + Modifier.fillMaxSize().then(paneFocus).pageSwipe({ !processSettingsPageVisible && appToChooseDisplay == null }) { dir -> + val keys = navigationKeys(s) + val at = keys.indexOf(railSelectionNow.value) + if (at >= 0) keys.getOrNull(at + dir)?.let { key -> + PageSwipe.direction = dir + showRailPage(key, false) + } + }, + { selected = it }, onAndroidAppClick = { app -> if (s.secondScreenDisplays.isEmpty()) a.onAndroidApp(app, null) else appToChooseDisplay = app diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt index 4a3b24ce6..0f1b579c9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt @@ -304,7 +304,7 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String, open, detailScroll, gridScrolled = { grid.firstVisibleItemIndex > 0 || grid.firstVisibleItemScrollOffset > 0 }, onDetailFocus = { detailFocused = it }, - modifier = modifier.padding(horizontal = if (narrow) 14.dp else 22.dp, vertical = if (narrow) 10.dp else 14.dp), + modifier = modifier.padding(horizontal = if (narrow) 14.dp else 22.dp).padding(top = if (narrow) 10.dp else 14.dp), wrap = { key, content -> StoreTheme(GameRef.parse(key).let { r -> r.store ?: r.game(s.steamGames)?.source?.let { Store.byId(it) } }) { content() } }, grid = { LibraryGrid( @@ -638,7 +638,7 @@ private fun LibraryRow(e: LibraryEntry, first: Boolean, navigation: Modifier, ch } @Composable -private fun HeaderIconButton(description: String, icon: androidx.compose.ui.graphics.vector.ImageVector, key: String, onClick: () -> Unit) { +internal fun HeaderIconButton(description: String, icon: androidx.compose.ui.graphics.vector.ImageVector, key: String, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } @@ -655,7 +655,7 @@ private fun HeaderIconButton(description: String, icon: androidx.compose.ui.grap /** Choosing Library in the rail lands on the first game, as Play Steam does on the Steam page. */ @Composable -private fun Modifier.paneEntry(): Modifier { +internal fun Modifier.paneEntry(): Modifier { val ff = LocalFrontFocus.current ?: return this androidx.compose.runtime.DisposableEffect(ff) { ff.primaryAttached++ diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PageSwipe.kt b/app/src/main/java/com/droiddeck/launcher/ui/PageSwipe.kt new file mode 100644 index 000000000..94c7a8cf3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/PageSwipe.kt @@ -0,0 +1,60 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.unit.dp +import kotlin.math.abs + +internal object PageSwipe { + var locks by mutableIntStateOf(0) + var direction by mutableIntStateOf(0) +} + +// Runs on the Final pass so carousels, sliders, scrollers and widgets that consumed the drag keep it. +@Composable +internal fun Modifier.pageSwipe(enabled: () -> Boolean, onSwipe: (Int) -> Unit): Modifier { + val enabledNow by rememberUpdatedState(enabled) + val onSwipeNow by rememberUpdatedState(onSwipe) + return pointerInput(Unit) { + val distance = 72.dp.toPx() + val flingDistance = 32.dp.toPx() + val flingVelocity = 900.dp.toPx() + val slop = viewConfiguration.touchSlop + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) + if (!enabledNow() || PageSwipe.locks > 0) return@awaitEachGesture + val tracker = VelocityTracker() + tracker.addPointerInputChange(down) + var total = Offset.Zero + var mine = false + while (true) { + val event = awaitPointerEvent(PointerEventPass.Final) + if (event.changes.size > 1) return@awaitEachGesture + val change = event.changes.firstOrNull { it.id == down.id } ?: return@awaitEachGesture + if (!change.pressed) break + if (!mine && change.isConsumed) return@awaitEachGesture + total += change.position - change.previousPosition + tracker.addPointerInputChange(change) + if (!mine && abs(total.x) > slop && abs(total.x) > abs(total.y) * 2f) mine = true + if (mine) change.consume() + } + if (!mine || PageSwipe.locks > 0) return@awaitEachGesture + val vx = tracker.calculateVelocity().x + val horizontal = abs(total.x) > abs(total.y) * 1.5f + val far = abs(total.x) > distance + val flung = abs(total.x) > flingDistance && abs(vx) > flingVelocity && (vx > 0) == (total.x > 0) + if (horizontal && (far || flung)) onSwipeNow(if (total.x < 0) 1 else -1) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index cd3a905c7..e3724dd83 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -157,7 +157,7 @@ private fun FlathubHero(s: FrontEndState, ring: Int) { StoreState.sections.values.flatten().mapNotNull { it.icon }.distinct().take(48).takeIf { it.size >= 8 }.orEmpty() } val busy = StoreState.busy == "setup" - BoxWithConstraints(Modifier.fillMaxSize().padding(horizontal = if (narrow) 14.dp else 22.dp, vertical = if (narrow) 10.dp else 14.dp)) { + BoxWithConstraints(Modifier.fillMaxSize().padding(horizontal = if (narrow) 14.dp else 22.dp).padding(top = if (narrow) 10.dp else 14.dp)) { val shape = RoundedCornerShape(20.dp) Box(Modifier.fillMaxWidth().height(maxHeight.coerceAtLeast(320.dp)).clip(shape).background(colors.background).border(1.dp, pal.line, shape)) { androidx.compose.animation.Crossfade(icons, animationSpec = Motion.tw(600), label = "flathubWall") { tiles -> @@ -316,7 +316,7 @@ internal fun Storefront(s: FrontEndState, a: FrontEndActions) { } Column( - Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) + Modifier.fillMaxSize().padding(horizontal = padH).padding(top = if (narrow) 10.dp else 14.dp) .bumpers( onPrevious = { if (openApp == null) mode = modes[(modes.indexOf(mode) + modes.size - 1) % modes.size] }, onNext = { if (openApp == null) mode = modes[(modes.indexOf(mode) + 1) % modes.size] }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 916e44e43..92b118d54 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -311,7 +311,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier val backLabel = stringResource(R.string.common_back) Box(modifier.onGloballyPositioned { hostAt = it.positionInRoot() }) { Column( - modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) + modifier = Modifier.fillMaxSize().padding(horizontal = padH).padding(top = if (narrow) 10.dp else 14.dp) .then(if (openGame != null && !arranging) Modifier else Modifier.bumpers( onPrevious = { val picked = Store.byId(focusedChip) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index 927a2c470..dd73939a7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -115,7 +115,7 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie } BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { val stacked = LocalNarrowPane.current || maxWidth < 600.dp - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) { + Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) { if (stacked) { StatusPanel(s, u, ua, me) Box(Modifier.height(18.dp)) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 5c719ca90..7e67b352b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -360,6 +360,7 @@ internal fun AdbTextField( onNext: (() -> Unit)? = null, onDone: (() -> Unit)? = null, focusOnShow: Boolean = false, + showLabel: Boolean = true, ) { val context = LocalContext.current val focusAsked = remember { booleanArrayOf(false) } @@ -374,7 +375,7 @@ internal fun AdbTextField( val colors = MaterialTheme.colorScheme Column(modifier = modifier.fillMaxWidth()) { - Text( + if (showLabel) Text( label, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index f78cf3014..e07f0fade 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -3221,4 +3221,50 @@ المجموعات سحابية إعدادات السحابة + البحث في التطبيقات + لا توجد تطبيقات تطابق ”%1$s“. + إضافة أداة + جارٍ تحميل الأدوات… + لا توجد تطبيقات على هذا الجهاز توفّر أدوات. + أعرض + أضيق + أطول + أقصر + نقل إلى الأمام + نقل إلى الخلف + إعدادات الأداة + إزالة الأداة + إضافة إلى مجلد + مجلد جديد + إزالة من المجلد + معلومات التطبيق + إلغاء التثبيت + إعادة التسمية + فك التجميع + مجلد + اسم المجلد + عرض + كل التطبيقات + الألعاب + المنزّلة + النظام + المخفية + إخفاء + إظهار في التطبيقات + إزالة الاختصار + إضافة إلى التطبيقات + إضافة + لا توجد تطبيقات مخفية. + الاسم أ–ي + الاسم ي–أ + المثبّتة مؤخرًا + المحدّثة مؤخرًا + + %d تطبيق + تطبيق واحد + تطبيقان + %d تطبيقات + %d تطبيقًا + %d تطبيق + diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 09e76582e..0861e3525 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -3071,4 +3071,47 @@ Colecciones Nube Configuración de nube + Buscar apps + Ninguna app coincide con «%1$s». + Agregar widget + Cargando widgets… + Ninguna app de este dispositivo ofrece widgets. + Más ancho + Más angosto + Más alto + Más bajo + Mover antes + Mover después + Configuración del widget + Quitar widget + Agregar a carpeta + Carpeta nueva + Quitar de la carpeta + Información de la app + Desinstalar + Cambiar nombre + Desagrupar + Carpeta + Nombre de la carpeta + Mostrar + Todas las apps + Juegos + Descargadas + Sistema + Ocultas + Ocultar + Mostrar en apps + Quitar acceso directo + Agregar a Apps + Agregar + No hay apps ocultas. + Nombre A–Z + Nombre Z–A + Instaladas recientemente + Actualizadas recientemente + + %d app + %d apps + %d apps + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 314b01f30..fecfd3a04 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -3085,4 +3085,46 @@ Колекции Облак Настройки на облака + Търсене на приложения + Няма приложения, отговарящи на „%1$s“. + Добавяне на приспособление + Приспособленията се зареждат… + Няма приложения с приспособления на това устройство. + По-широко + По-тясно + По-високо + По-ниско + Преместване по-рано + Преместване по-късно + Настройки на приспособлението + Премахване на приспособлението + Добавяне в папка + Нова папка + Премахване от папката + Информация за приложението + Деинсталиране + Преименуване + Разгрупиране + Папка + Име на папката + Показване + Всички приложения + Игри + Изтеглени + Системни + Скрити + Скриване + Показване в приложенията + Премахване на прекия път + Добавяне към приложенията + Добавяне + Няма скрити приложения. + Име А–Я + Име Я–А + Наскоро инсталирани + Наскоро актуализирани + + %d приложение + %d приложения + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e0278767d..b9694894e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -3153,4 +3153,48 @@ Kolekce Cloud Nastavení cloudu + Hledat aplikace + Výrazu „%1$s“ neodpovídá žádná aplikace. + Přidat widget + Načítání widgetů… + Žádná aplikace v zařízení nenabízí widgety. + Širší + Užší + Vyšší + Nižší + Posunout dříve + Posunout později + Nastavení widgetu + Odebrat widget + Přidat do složky + Nová složka + Odebrat ze složky + Informace o aplikaci + Odinstalovat + Přejmenovat + Zrušit složku + Složka + Název složky + Zobrazit + Všechny aplikace + Hry + Stažené + Systémové + Skryté + Skrýt + Zobrazit v aplikacích + Odebrat zkratku + Přidat do aplikací + Přidat + Žádné skryté aplikace. + Název A–Z + Název Z–A + Nedávno nainstalované + Nedávno aktualizované + + %d aplikace + %d aplikace + %d aplikace + %d aplikací + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 789cbc762..af13b452d 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -3085,4 +3085,46 @@ Samlinger Sky Skyindstillinger + Søg i apps + Ingen apps matcher “%1$s”. + Tilføj widget + Indlæser widgets… + Ingen apps på enheden tilbyder widgets. + Bredere + Smallere + Højere + Lavere + Flyt tidligere + Flyt senere + Widgetindstillinger + Fjern widget + Føj til mappe + Ny mappe + Fjern fra mappe + Appinfo + Afinstaller + Omdøb + Opløs + Mappe + Mappenavn + Vis + Alle apps + Spil + Downloadet + System + Skjulte + Skjul + Vis i apps + Fjern genvej + Føj til Apps + Tilføj + Ingen skjulte apps. + Navn A–Å + Navn Å–A + Senest installeret + Senest opdateret + + %d app + %d apps + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1ddc2655d..9ffcfe1fd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -3085,4 +3085,46 @@ Sammlungen Cloud Cloud-Einstellungen + Apps durchsuchen + Keine Apps passen zu „%1$s“. + Widget hinzufügen + Widgets werden geladen… + Keine App auf diesem Gerät bietet Widgets an. + Breiter + Schmaler + Höher + Niedriger + Nach vorne + Nach hinten + Widget-Einstellungen + Widget entfernen + Zu Ordner hinzufügen + Neuer Ordner + Aus Ordner entfernen + App-Info + Deinstallieren + Umbenennen + Auflösen + Ordner + Ordnername + Anzeigen + Alle Apps + Spiele + Heruntergeladen + System + Ausgeblendet + Ausblenden + In Apps anzeigen + Verknüpfung entfernen + Zu Apps hinzufügen + Hinzufügen + Keine ausgeblendeten Apps. + Name A–Z + Name Z–A + Zuletzt installiert + Zuletzt aktualisiert + + %d App + %d Apps + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 85e3befcd..c8588ac9c 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -3085,4 +3085,46 @@ Συλλογές Cloud Ρυθμίσεις cloud + Αναζήτηση εφαρμογών + Καμία εφαρμογή δεν ταιριάζει με «%1$s». + Προσθήκη γραφικού στοιχείου + Φόρτωση γραφικών στοιχείων… + Καμία εφαρμογή στη συσκευή δεν προσφέρει γραφικά στοιχεία. + Πλατύτερο + Στενότερο + Ψηλότερο + Χαμηλότερο + Μετακίνηση νωρίτερα + Μετακίνηση αργότερα + Ρυθμίσεις γραφικού στοιχείου + Αφαίρεση γραφικού στοιχείου + Προσθήκη σε φάκελο + Νέος φάκελος + Αφαίρεση από φάκελο + Πληροφορίες εφαρμογής + Απεγκατάσταση + Μετονομασία + Κατάργηση ομάδας + Φάκελος + Όνομα φακέλου + Εμφάνιση + Όλες οι εφαρμογές + Παιχνίδια + Ληφθείσες + Συστήματος + Κρυφές + Απόκρυψη + Εμφάνιση στις εφαρμογές + Αφαίρεση συντόμευσης + Προσθήκη στις Εφαρμογές + Προσθήκη + Δεν υπάρχουν κρυφές εφαρμογές. + Όνομα Α–Ω + Όνομα Ω–Α + Πρόσφατα εγκατεστημένες + Πρόσφατα ενημερωμένες + + %d εφαρμογή + %d εφαρμογές + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f6d49f6f9..82af91332 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -3121,4 +3121,47 @@ Colecciones Nube Ajustes de nube + Buscar aplicaciones + Ninguna aplicación coincide con «%1$s». + Añadir widget + Cargando widgets… + Ninguna aplicación de este dispositivo ofrece widgets. + Más ancho + Más estrecho + Más alto + Más bajo + Mover antes + Mover después + Ajustes del widget + Quitar widget + Añadir a carpeta + Carpeta nueva + Quitar de la carpeta + Información de la aplicación + Desinstalar + Cambiar nombre + Desagrupar + Carpeta + Nombre de la carpeta + Mostrar + Todas las aplicaciones + Juegos + Descargadas + Sistema + Ocultas + Ocultar + Mostrar en aplicaciones + Quitar acceso directo + Añadir a Aplicaciones + Añadir + No hay aplicaciones ocultas. + Nombre A–Z + Nombre Z–A + Instaladas recientemente + Actualizadas recientemente + + %d aplicación + %d aplicaciones + %d aplicaciones + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 79737e2e0..3ad1e9247 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -3085,4 +3085,46 @@ Kokoelmat Pilvi Pilviasetukset + Hae sovelluksia + Mikään sovellus ei vastaa hakua ”%1$s”. + Lisää widget + Ladataan widgettejä… + Mikään tämän laitteen sovellus ei tarjoa widgettejä. + Leveämpi + Kapeampi + Korkeampi + Matalampi + Siirrä aiemmaksi + Siirrä myöhemmäksi + Widgetin asetukset + Poista widget + Lisää kansioon + Uusi kansio + Poista kansiosta + Sovelluksen tiedot + Poista asennus + Nimeä uudelleen + Pura kansio + Kansio + Kansion nimi + Näytä + Kaikki sovellukset + Pelit + Ladatut + Järjestelmä + Piilotetut + Piilota + Näytä sovelluksissa + Poista pikakuvake + Lisää sovelluksiin + Lisää + Ei piilotettuja sovelluksia. + Nimi A–Ö + Nimi Ö–A + Äskettäin asennetut + Äskettäin päivitetyt + + %d sovellus + %d sovellusta + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 97cb78aa5..ef4fdb8f5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -3121,4 +3121,47 @@ Collections Cloud Paramètres cloud + Rechercher des applications + Aucune application ne correspond à « %1$s ». + Ajouter un widget + Chargement des widgets… + Aucune application de cet appareil ne propose de widgets. + Plus large + Plus étroit + Plus haut + Moins haut + Avancer + Reculer + Paramètres du widget + Retirer le widget + Ajouter à un dossier + Nouveau dossier + Retirer du dossier + Infos sur l’application + Désinstaller + Renommer + Dégrouper + Dossier + Nom du dossier + Afficher + Toutes les applications + Jeux + Téléchargées + Système + Masquées + Masquer + Afficher dans les applications + Retirer le raccourci + Ajouter aux applications + Ajouter + Aucune application masquée. + Nom A–Z + Nom Z–A + Installées récemment + Mises à jour récemment + + %d application + %d applications + %d applications + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index a1edf261e..ea458fa23 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -3085,4 +3085,46 @@ संग्रह क्लाउड क्लाउड सेटिंग्स + ऐप खोजें + “%1$s” से कोई ऐप मेल नहीं खाता। + विजेट जोड़ें + विजेट लोड हो रहे हैं… + इस डिवाइस पर कोई ऐप विजेट नहीं देता। + चौड़ा + पतला + लंबा + छोटा + पहले ले जाएँ + बाद में ले जाएँ + विजेट सेटिंग + विजेट हटाएँ + फ़ोल्डर में जोड़ें + नया फ़ोल्डर + फ़ोल्डर से हटाएँ + ऐप की जानकारी + अनइंस्टॉल करें + नाम बदलें + समूह तोड़ें + फ़ोल्डर + फ़ोल्डर का नाम + दिखाएँ + सभी ऐप + गेम + डाउनलोड किए गए + सिस्टम + छिपे हुए + छिपाएँ + ऐप में दिखाएँ + शॉर्टकट हटाएँ + ऐप में जोड़ें + जोड़ें + कोई छिपा हुआ ऐप नहीं। + नाम A–Z + नाम Z–A + हाल ही में इंस्टॉल किए गए + हाल ही में अपडेट किए गए + + %d ऐप + %d ऐप + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 747b7ed5c..8d8f2774d 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -3085,4 +3085,46 @@ Gyűjtemények Felhő Felhőbeállítások + Alkalmazások keresése + Egy alkalmazás sem felel meg ennek: „%1$s”. + Modul hozzáadása + Modulok betöltése… + Az eszközön egyik alkalmazás sem kínál modult. + Szélesebb + Keskenyebb + Magasabb + Alacsonyabb + Előrébb + Hátrébb + Modulbeállítások + Modul eltávolítása + Hozzáadás mappához + Új mappa + Eltávolítás a mappából + Alkalmazásinformáció + Eltávolítás + Átnevezés + Szétbontás + Mappa + Mappa neve + Megjelenítés + Összes alkalmazás + Játékok + Letöltött + Rendszer + Rejtett + Elrejtés + Megjelenítés az alkalmazások között + Parancsikon eltávolítása + Hozzáadás az alkalmazásokhoz + Hozzáadás + Nincsenek rejtett alkalmazások. + Név A–Z + Név Z–A + Nemrég telepített + Nemrég frissített + + %d alkalmazás + %d alkalmazás + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 79291d8e7..c0c4fa21f 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -3075,4 +3075,45 @@ Koleksi Cloud Pengaturan cloud + Cari aplikasi + Tidak ada aplikasi yang cocok dengan “%1$s”. + Tambahkan widget + Memuat widget… + Tidak ada aplikasi di perangkat ini yang menyediakan widget. + Lebih lebar + Lebih sempit + Lebih tinggi + Lebih pendek + Pindahkan ke depan + Pindahkan ke belakang + Setelan widget + Hapus widget + Tambahkan ke folder + Folder baru + Keluarkan dari folder + Info aplikasi + Uninstal + Ganti nama + Pisahkan + Folder + Nama folder + Tampilkan + Semua aplikasi + Game + Didownload + Sistem + Tersembunyi + Sembunyikan + Tampilkan di aplikasi + Hapus pintasan + Tambahkan ke Aplikasi + Tambahkan + Tidak ada aplikasi tersembunyi. + Nama A–Z + Nama Z–A + Baru diinstal + Baru diperbarui + + %d aplikasi + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index bbe535146..c20bdc151 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -3119,4 +3119,47 @@ Raccolte Cloud Impostazioni cloud + Cerca app + Nessuna app corrisponde a «%1$s». + Aggiungi widget + Caricamento widget… + Nessuna app su questo dispositivo offre widget. + Più largo + Più stretto + Più alto + Più basso + Sposta prima + Sposta dopo + Impostazioni widget + Rimuovi widget + Aggiungi a cartella + Nuova cartella + Rimuovi dalla cartella + Info app + Disinstalla + Rinomina + Separa + Cartella + Nome cartella + Mostra + Tutte le app + Giochi + Scaricate + Sistema + Nascoste + Nascondi + Mostra nelle app + Rimuovi scorciatoia + Aggiungi ad App + Aggiungi + Nessuna app nascosta. + Nome A–Z + Nome Z–A + Installate di recente + Aggiornate di recente + + %d app + %d app + %d app + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 19f6dbfae..87a910cae 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -3062,4 +3062,45 @@ コレクション クラウド クラウド設定 + アプリを検索 + 「%1$s」に一致するアプリはありません。 + ウィジェットを追加 + ウィジェットを読み込み中… + このデバイスにはウィジェットを提供するアプリがありません。 + 幅を広く + 幅を狭く + 高く + 低く + 前へ移動 + 後ろへ移動 + ウィジェットの設定 + ウィジェットを削除 + フォルダに追加 + 新しいフォルダ + フォルダから削除 + アプリ情報 + アンインストール + 名前を変更 + グループを解除 + フォルダ + フォルダ名 + 表示 + すべてのアプリ + ゲーム + ダウンロード済み + システム + 非表示 + 非表示にする + アプリに表示 + ショートカットを削除 + アプリに追加 + 追加 + 非表示のアプリはありません。 + 名前 A–Z + 名前 Z–A + 最近インストール + 最近更新 + + %d 個のアプリ + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9163a3a7d..10d6fd46d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -3062,4 +3062,45 @@ 컬렉션 클라우드 클라우드 설정 + 앱 검색 + ‘%1$s’와(과) 일치하는 앱이 없습니다. + 위젯 추가 + 위젯 불러오는 중… + 이 기기에 위젯을 제공하는 앱이 없습니다. + 더 넓게 + 더 좁게 + 더 높게 + 더 낮게 + 앞으로 이동 + 뒤로 이동 + 위젯 설정 + 위젯 삭제 + 폴더에 추가 + 새 폴더 + 폴더에서 제거 + 앱 정보 + 제거 + 이름 변경 + 그룹 해제 + 폴더 + 폴더 이름 + 표시 + 모든 앱 + 게임 + 다운로드됨 + 시스템 + 숨김 + 숨기기 + 앱에 표시 + 바로가기 삭제 + 앱에 추가 + 추가 + 숨긴 앱이 없습니다. + 이름 A–Z + 이름 Z–A + 최근 설치 + 최근 업데이트 + + 앱 %d개 + diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index eb41beb9b..098f2429a 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -3060,4 +3060,45 @@ Koleksi Awan Tetapan awan + Cari apl + Tiada apl sepadan dengan “%1$s”. + Tambah widget + Memuatkan widget… + Tiada apl pada peranti ini yang menawarkan widget. + Lebih lebar + Lebih sempit + Lebih tinggi + Lebih rendah + Alih ke depan + Alih ke belakang + Tetapan widget + Alih keluar widget + Tambah pada folder + Folder baharu + Alih keluar daripada folder + Maklumat apl + Nyahpasang + Namakan semula + Leraikan + Folder + Nama folder + Tunjukkan + Semua apl + Permainan + Dimuat turun + Sistem + Tersembunyi + Sembunyikan + Tunjukkan dalam apl + Alih keluar pintasan + Tambah pada Apl + Tambah + Tiada apl tersembunyi. + Nama A–Z + Nama Z–A + Dipasang baru-baru ini + Dikemas kini baru-baru ini + + %d apl + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 75653c225..e95a57c22 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -3085,4 +3085,46 @@ Samlinger Sky Skyinnstillinger + Søk i apper + Ingen apper samsvarer med «%1$s». + Legg til modul + Laster inn moduler… + Ingen apper på enheten tilbyr moduler. + Bredere + Smalere + Høyere + Lavere + Flytt tidligere + Flytt senere + Modulinnstillinger + Fjern modul + Legg til i mappe + Ny mappe + Fjern fra mappe + Appinfo + Avinstaller + Gi nytt navn + Løs opp + Mappe + Mappenavn + Vis + Alle apper + Spill + Nedlastet + System + Skjult + Skjul + Vis i apper + Fjern snarvei + Legg til i Apper + Legg til + Ingen skjulte apper. + Navn A–Å + Navn Å–A + Nylig installert + Nylig oppdatert + + %d app + %d apper + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 53748c36a..5cd7fdf36 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -3085,4 +3085,46 @@ Collecties Cloud Cloudinstellingen + Apps zoeken + Geen apps gevonden voor ‘%1$s’. + Widget toevoegen + Widgets laden… + Geen enkele app op dit apparaat biedt widgets. + Breder + Smaller + Hoger + Lager + Naar voren + Naar achteren + Widgetinstellingen + Widget verwijderen + Toevoegen aan map + Nieuwe map + Uit map verwijderen + App-info + Verwijderen + Naam wijzigen + Opheffen + Map + Mapnaam + Tonen + Alle apps + Games + Gedownload + Systeem + Verborgen + Verbergen + Tonen in apps + Snelkoppeling verwijderen + Toevoegen aan Apps + Toevoegen + Geen verborgen apps. + Naam A–Z + Naam Z–A + Recent geïnstalleerd + Recent bijgewerkt + + %d app + %d apps + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 37619c513..4e87191fe 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -3156,4 +3156,48 @@ Kolekcje Chmura Ustawienia chmury + Szukaj aplikacji + Żadna aplikacja nie pasuje do „%1$s”. + Dodaj widżet + Wczytywanie widżetów… + Żadna aplikacja na tym urządzeniu nie oferuje widżetów. + Szerszy + Węższy + Wyższy + Niższy + Przesuń wcześniej + Przesuń później + Ustawienia widżetu + Usuń widżet + Dodaj do folderu + Nowy folder + Usuń z folderu + Informacje o aplikacji + Odinstaluj + Zmień nazwę + Rozgrupuj + Folder + Nazwa folderu + Pokaż + Wszystkie aplikacje + Gry + Pobrane + Systemowe + Ukryte + Ukryj + Pokaż w aplikacjach + Usuń skrót + Dodaj do aplikacji + Dodaj + Brak ukrytych aplikacji. + Nazwa A–Z + Nazwa Z–A + Ostatnio zainstalowane + Ostatnio zaktualizowane + + %d aplikacja + %d aplikacje + %d aplikacji + %d aplikacji + diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 177799c1a..ceac1c534 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -3119,4 +3119,47 @@ Coleções Nuvem Definições da nuvem + Pesquisar aplicações + Nenhuma aplicação corresponde a “%1$s”. + Adicionar widget + A carregar widgets… + Nenhuma aplicação neste dispositivo oferece widgets. + Mais largo + Mais estreito + Mais alto + Mais baixo + Mover para antes + Mover para depois + Definições do widget + Remover widget + Adicionar a pasta + Nova pasta + Remover da pasta + Informações da aplicação + Desinstalar + Mudar o nome + Desagrupar + Pasta + Nome da pasta + Mostrar + Todas as aplicações + Jogos + Transferidas + Sistema + Ocultas + Ocultar + Mostrar nas aplicações + Remover atalho + Adicionar às Aplicações + Adicionar + Nenhuma aplicação oculta. + Nome A–Z + Nome Z–A + Instaladas recentemente + Atualizadas recentemente + + %d aplicação + %d aplicações + %d aplicações + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2bdda9aac..825ef7b95 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -3119,4 +3119,47 @@ Coleções Nuvem Configurações da nuvem + Pesquisar apps + Nenhum app corresponde a “%1$s”. + Adicionar widget + Carregando widgets… + Nenhum app neste dispositivo oferece widgets. + Mais largo + Mais estreito + Mais alto + Mais baixo + Mover para antes + Mover para depois + Configurações do widget + Remover widget + Adicionar à pasta + Nova pasta + Remover da pasta + Informações do app + Desinstalar + Renomear + Desagrupar + Pasta + Nome da pasta + Mostrar + Todos os apps + Jogos + Baixados + Sistema + Ocultos + Ocultar + Mostrar nos apps + Remover atalho + Adicionar aos Apps + Adicionar + Nenhum app oculto. + Nome A–Z + Nome Z–A + Instalados recentemente + Atualizados recentemente + + %d app + %d apps + %d apps + diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 49a0503c6..b95ef4857 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -3119,4 +3119,47 @@ Colecții Cloud Setări cloud + Caută aplicații + Nicio aplicație nu se potrivește cu „%1$s”. + Adaugă widget + Se încarcă widgeturile… + Nicio aplicație de pe acest dispozitiv nu oferă widgeturi. + Mai lat + Mai îngust + Mai înalt + Mai scund + Mută mai devreme + Mută mai târziu + Setări widget + Elimină widgetul + Adaugă în dosar + Dosar nou + Elimină din dosar + Informații despre aplicație + Dezinstalează + Redenumește + Degrupează + Dosar + Numele dosarului + Afișează + Toate aplicațiile + Jocuri + Descărcate + Sistem + Ascunse + Ascunde + Afișează în aplicații + Elimină comanda rapidă + Adaugă în Aplicații + Adaugă + Nicio aplicație ascunsă. + Nume A–Z + Nume Z–A + Instalate recent + Actualizate recent + + %d aplicație + %d aplicații + %d de aplicații + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b4d440f2c..483d86ab5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -3155,4 +3155,48 @@ Коллекции Облако Настройки облака + Поиск приложений + Нет приложений по запросу «%1$s». + Добавить виджет + Загрузка виджетов… + На этом устройстве нет приложений с виджетами. + Шире + Уже + Выше + Ниже + Переместить раньше + Переместить позже + Настройки виджета + Удалить виджет + Добавить в папку + Новая папка + Убрать из папки + О приложении + Удалить + Переименовать + Разгруппировать + Папка + Название папки + Показать + Все приложения + Игры + Загруженные + Системные + Скрытые + Скрыть + Показывать в приложениях + Удалить ярлык + Добавить в приложения + Добавить + Скрытых приложений нет. + Название А–Я + Название Я–А + Недавно установленные + Недавно обновлённые + + %d приложение + %d приложения + %d приложений + %d приложения + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 35c26e8f2..72c27705b 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -3085,4 +3085,46 @@ Samlingar Moln Molninställningar + Sök appar + Inga appar matchar ”%1$s”. + Lägg till widget + Läser in widgetar… + Inga appar på enheten erbjuder widgetar. + Bredare + Smalare + Högre + Lägre + Flytta tidigare + Flytta senare + Widgetinställningar + Ta bort widget + Lägg till i mapp + Ny mapp + Ta bort från mapp + Appinfo + Avinstallera + Byt namn + Dela upp + Mapp + Mappnamn + Visa + Alla appar + Spel + Nedladdade + System + Dolda + Dölj + Visa i appar + Ta bort genväg + Lägg till i Appar + Lägg till + Inga dolda appar. + Namn A–Ö + Namn Ö–A + Senast installerade + Senast uppdaterade + + %d app + %d appar + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 348740686..9cdb0a55b 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -3060,4 +3060,45 @@ คอลเลกชัน คลาวด์ การตั้งค่าคลาวด์ + ค้นหาแอป + ไม่มีแอปที่ตรงกับ “%1$s” + เพิ่มวิดเจ็ต + กำลังโหลดวิดเจ็ต… + ไม่มีแอปในอุปกรณ์นี้ที่มีวิดเจ็ต + กว้างขึ้น + แคบลง + สูงขึ้น + เตี้ยลง + ย้ายไปก่อน + ย้ายไปหลัง + การตั้งค่าวิดเจ็ต + นำวิดเจ็ตออก + เพิ่มลงในโฟลเดอร์ + โฟลเดอร์ใหม่ + นำออกจากโฟลเดอร์ + ข้อมูลแอป + ถอนการติดตั้ง + เปลี่ยนชื่อ + ยกเลิกกลุ่ม + โฟลเดอร์ + ชื่อโฟลเดอร์ + แสดง + แอปทั้งหมด + เกม + ที่ดาวน์โหลด + ระบบ + ที่ซ่อนไว้ + ซ่อน + แสดงในแอป + นำทางลัดออก + เพิ่มลงในแอป + เพิ่ม + ไม่มีแอปที่ซ่อนไว้ + ชื่อ A–Z + ชื่อ Z–A + ติดตั้งล่าสุด + อัปเดตล่าสุด + + %d แอป + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 78cdc1f70..3dea1e5bc 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -3087,4 +3087,46 @@ Koleksiyonlar Bulut Bulut ayarları + Uygulama ara + “%1$s” ile eşleşen uygulama yok. + Widget ekle + Widget’lar yükleniyor… + Bu cihazdaki hiçbir uygulama widget sunmuyor. + Daha geniş + Daha dar + Daha uzun + Daha kısa + Öne taşı + Arkaya taşı + Widget ayarları + Widget’ı kaldır + Klasöre ekle + Yeni klasör + Klasörden çıkar + Uygulama bilgileri + Kaldır + Yeniden adlandır + Grubu çöz + Klasör + Klasör adı + Göster + Tüm uygulamalar + Oyunlar + İndirilenler + Sistem + Gizli + Gizle + Uygulamalarda göster + Kısayolu kaldır + Uygulamalara ekle + Ekle + Gizli uygulama yok. + Ad A–Z + Ad Z–A + Son yüklenenler + Son güncellenenler + + %d uygulama + %d uygulama + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 050fd0fdc..3084c36ba 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -3153,4 +3153,48 @@ Колекції Хмара Налаштування хмари + Пошук застосунків + Немає застосунків за запитом «%1$s». + Додати віджет + Завантаження віджетів… + На цьому пристрої немає застосунків із віджетами. + Ширше + Вужче + Вище + Нижче + Перемістити раніше + Перемістити пізніше + Налаштування віджета + Вилучити віджет + Додати до папки + Нова папка + Вилучити з папки + Про застосунок + Видалити + Перейменувати + Розгрупувати + Папка + Назва папки + Показати + Усі застосунки + Ігри + Завантажені + Системні + Приховані + Приховати + Показувати в застосунках + Вилучити ярлик + Додати до застосунків + Додати + Прихованих застосунків немає. + Назва А–Я + Назва Я–А + Нещодавно встановлені + Нещодавно оновлені + + %d застосунок + %d застосунки + %d застосунків + %d застосунку + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 9c75292d7..0cfe20866 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -3060,4 +3060,45 @@ Bộ sưu tập Đám mây Cài đặt đám mây + Tìm ứng dụng + Không có ứng dụng nào khớp với “%1$s”. + Thêm tiện ích + Đang tải tiện ích… + Không có ứng dụng nào trên thiết bị này cung cấp tiện ích. + Rộng hơn + Hẹp hơn + Cao hơn + Thấp hơn + Chuyển lên trước + Chuyển ra sau + Cài đặt tiện ích + Xóa tiện ích + Thêm vào thư mục + Thư mục mới + Xóa khỏi thư mục + Thông tin ứng dụng + Gỡ cài đặt + Đổi tên + Bỏ nhóm + Thư mục + Tên thư mục + Hiển thị + Tất cả ứng dụng + Trò chơi + Đã tải xuống + Hệ thống + Đã ẩn + Ẩn + Hiện trong ứng dụng + Xóa lối tắt + Thêm vào Ứng dụng + Thêm + Không có ứng dụng nào bị ẩn. + Tên A–Z + Tên Z–A + Mới cài đặt + Mới cập nhật + + %d ứng dụng + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ade2de5de..416839104 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -3064,4 +3064,45 @@ 收藏集 云 云设置 + 搜索应用 + 没有与“%1$s”匹配的应用。 + 添加微件 + 正在加载微件… + 此设备上没有提供微件的应用。 + 加宽 + 缩窄 + 加高 + 降低 + 前移 + 后移 + 微件设置 + 移除微件 + 添加到文件夹 + 新建文件夹 + 从文件夹中移除 + 应用信息 + 卸载 + 重命名 + 取消分组 + 文件夹 + 文件夹名称 + 显示 + 所有应用 + 游戏 + 已下载 + 系统 + 已隐藏 + 隐藏 + 在应用中显示 + 移除快捷方式 + 添加到应用 + 添加 + 没有隐藏的应用。 + 名称 A–Z + 名称 Z–A + 最近安装 + 最近更新 + + %d 个应用 + diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 94ed816a9..ee0daff39 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -3063,4 +3063,45 @@ 收藏集 雲端 雲端設定 + 搜尋應用程式 + 沒有符合「%1$s」的應用程式。 + 新增小工具 + 正在載入小工具… + 此裝置上沒有提供小工具的應用程式。 + 加闊 + 收窄 + 加高 + 降低 + 向前移 + 向後移 + 小工具設定 + 移除小工具 + 加入資料夾 + 新資料夾 + 從資料夾移除 + 應用程式資料 + 解除安裝 + 重新命名 + 取消組合 + 資料夾 + 資料夾名稱 + 顯示 + 所有應用程式 + 遊戲 + 已下載 + 系統 + 已隱藏 + 隱藏 + 在應用程式中顯示 + 移除捷徑 + 加入應用程式 + 新增 + 沒有隱藏的應用程式。 + 名稱 A–Z + 名稱 Z–A + 最近安裝 + 最近更新 + + %d 個應用程式 + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0c3257ead..27effeacb 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -3063,4 +3063,45 @@ 收藏集 雲端 雲端設定 + 搜尋應用程式 + 沒有符合「%1$s」的應用程式。 + 新增小工具 + 正在載入小工具… + 這部裝置上沒有提供小工具的應用程式。 + 加寬 + 縮窄 + 加高 + 降低 + 往前移 + 往後移 + 小工具設定 + 移除小工具 + 新增至資料夾 + 新資料夾 + 從資料夾移除 + 應用程式資訊 + 解除安裝 + 重新命名 + 取消群組 + 資料夾 + 資料夾名稱 + 顯示 + 所有應用程式 + 遊戲 + 已下載 + 系統 + 已隱藏 + 隱藏 + 在應用程式中顯示 + 移除捷徑 + 新增至應用程式 + 新增 + 沒有隱藏的應用程式。 + 名稱 A–Z + 名稱 Z–A + 最近安裝 + 最近更新 + + %d 個應用程式 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 955caad10..8979e00b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1081,6 +1081,48 @@ Android apps %1$d apps No launchable Android apps found. + Search apps + No apps match “%1$s”. + Add widget + Loading widgets… + No apps on this device offer widgets. + Wider + Narrower + Taller + Shorter + Move earlier + Move later + Widget settings + Remove widget + Add to folder + New folder + Remove from folder + App info + Uninstall + Rename + Ungroup + Folder + Folder name + Show + All apps + Games + Downloaded + System + Hidden + Hide + Show in apps + Remove shortcut + Add to Apps + Add + No hidden apps. + + %d app + %d apps + + Name A–Z + Name Z–A + Recently installed + Recently updated ● Desktop installed Installs on first open Available to install