initial profiles support

This commit is contained in:
Chloe committed 2025-01-22 23:14:08 -07:00
1 parent 19f47f138a
commit 071cf40d42
11 files changed
+422 -15

No files matched your search

@@ -40,7 +40,7 @@ class UserDirectoryProvider : DocumentsProvider() {
override fun onCreate(): Boolean {
val context = context ?: return false
rootDir = LaunchUtils.getBaseDirectory(context)
rootDir = LaunchUtils.getBaseDirectory(context, true)
return true
}
@@ -174,6 +174,11 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
)
}
OptionsGroup(title = stringResource(R.string.preference_profiles)) {
ProfileCreateCard()
ProfileSelectCard()
}
}
}
}
@@ -1,6 +1,8 @@
package com.geode.launcher.preferences
import android.content.Context
import android.content.Intent
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,6 +21,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
@@ -32,9 +35,11 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -55,10 +60,13 @@ import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.LabelledText
import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.Profile
import com.geode.launcher.utils.ProfileManager
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.system.exitProcess
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
@@ -556,10 +564,12 @@ internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
@Composable
fun <T> SelectOption(name: String, value: T) {
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
val currentValue = LocalSelectValue.current
val setValue = LocalSelectSetValue.current
val isSelected = currentValue.equals(value)
// do not give the row or column padding!! it messes up the selection effect
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -567,15 +577,221 @@ fun <T> SelectOption(name: String, value: T) {
.fillMaxWidth()
.clickable(
onClick = { setValue(value as Any) },
role = Role.RadioButton
role = Role.RadioButton,
enabled = enabled
)
.padding(horizontal = 12.dp)
) {
RadioButton(
selected = currentValue.equals(value),
onClick = { setValue(value as Any) }
)
Text(name, style = Typography.bodyMedium)
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = isSelected,
onClick = { setValue(value as Any) },
enabled = enabled
)
Text(name, style = Typography.bodyMedium)
}
leadingContent(isSelected)
}
}
@Composable
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
var enteredValue by remember { mutableStateOf("") }
val filename = enteredValue.take(16)
.lowercase()
.map {
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
it else '_'
}
.joinToString("")
val context = LocalContext.current
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
val profileManager = ProfileManager.get(context)
val currentProfiles = remember {
profileManager.getProfiles().map { it.path }.toSet()
}
val isDuplicate = currentProfiles.contains(filename)
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(stringResource(R.string.preference_profiles_create))
},
text = {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1.0f, false)
) {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(stringResource(R.string.preference_profiles_create_name))
},
isError = isDuplicate
)
}
if (isDuplicate) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_duplicate))
} else if (!minimumReached) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_info, filename))
}
}
},
confirmButton = {
TextButton(
onClick = {
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
onDismissRequest()
},
enabled = !minimumReached && !isDuplicate
) {
Text(stringResource(R.string.preference_profiles_create_action))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun ProfileCreateCard() {
var showDialog by remember { mutableStateOf(false) }
OptionsButton(
title = stringResource(R.string.preference_profiles_create),
icon = {
Icon(painterResource(R.drawable.icon_person_add), contentDescription = null)
}
) {
showDialog = true
}
if (showDialog) {
ProfileCreateDialog(onDismissRequest = {
showDialog = false
})
}
}
@Composable
fun ProfileSelectCard() {
val context = LocalContext.current
val profileManager = ProfileManager.get(context)
val currentProfileId = remember {
profileManager.getCurrentProfile()
}
var currentProfileName = remember {
val savedProfile = currentProfileId
if (savedProfile == null) {
"Default"
} else {
profileManager.getProfile(savedProfile)
?.name ?: savedProfile
}
}
val clearedProfiles = remember { mutableStateListOf<String>() }
val profileList by profileManager.storedProfiles.collectAsState()
var showDialog by remember { mutableStateOf(false) }
OptionsButton(
title = stringResource(R.string.preference_profiles_select),
description = stringResource(R.string.preference_profiles_current, currentProfileName)
) {
showDialog = true
}
val activity = LocalActivity.current
if (showDialog) {
SelectDialog(
title = stringResource(R.string.preference_profiles_select),
onDismissRequest = {
showDialog = false
},
onSelect = {
showDialog = false
val selectedProfileId = it.takeIf { it.isNotEmpty() }
if (clearedProfiles.isNotEmpty()) {
profileManager.deleteProfiles(clearedProfiles)
}
if (currentProfileId != selectedProfileId) {
profileManager
.setCurrentProfile(selectedProfileId)
activity?.run {
packageManager.getLaunchIntentForPackage(packageName)?.also {
val mainIntent = Intent.makeRestartActivityTask(it.component)
startActivity(mainIntent)
exitProcess(0)
}
}
}
},
initialValue = currentProfileId ?: "",
) {
SelectOption("Default", "")
profileList.forEach { profile ->
val path = profile.path
SelectOption(
stringResource(R.string.preference_profiles_select_value, profile.name, path),
path,
enabled = !clearedProfiles.contains(path),
leadingContent = { selected ->
IconButton(onClick = {
if (clearedProfiles.contains(path)) {
clearedProfiles.remove(path)
} else {
clearedProfiles.add(path)
}
}, enabled = !selected) {
if (clearedProfiles.contains(path)) {
Icon(
painterResource(R.drawable.icon_undo),
contentDescription = stringResource(R.string.preference_profiles_delete_undo_alt)
)
} else {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.preference_profiles_delete_alt)
)
}
}
}
)
}
}
}
}
@@ -524,7 +524,7 @@ object GeodeUtils {
return false
}
return capabilityListener.get()?.onCapabilityAdded(capability) ?: false
return capabilityListener.get()?.onCapabilityAdded(capability) == true
}
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
@@ -47,7 +47,7 @@ object LaunchUtils {
/**
* Returns the directory that Geode/the game should base itself off of.
*/
fun getBaseDirectory(context: Context): File {
fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File {
// deprecated, but seems to be the best choice of directory (i forced mat to test it)
// also, is getting the first item the correct choice here?? what do they mean
@Suppress("DEPRECATION")
@@ -58,6 +58,14 @@ object LaunchUtils {
val noMediaPath = File(dir, ".nomedia")
noMediaPath.createNewFile()
val currentProfile = ProfileManager.get(context).getCurrentProfile()
if (currentProfile != null && !ignoreProfile) {
val profile = File(dir, "profiles/$currentProfile/")
profile.mkdirs()
return profile
}
return dir
}
@@ -20,8 +20,6 @@ import com.geode.launcher.BuildConfig
*/
class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
companion object {
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
@Composable
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
return usePreference(
@@ -70,7 +68,9 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
preferenceSet: (PreferenceUtils, Key, T) -> Unit
): MutableState<T> {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
val currentProfile = remember { ProfileManager.get(context).getCurrentFileKey() }
val sharedPreferences = context.getSharedPreferences(currentProfile, Context.MODE_PRIVATE)
val preferences = get(sharedPreferences)
@@ -114,7 +114,8 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
}
fun get(context: Context): PreferenceUtils {
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
val currentFileKey = ProfileManager.get(context).getCurrentFileKey()
val sharedPreferences = context.getSharedPreferences(currentFileKey, Context.MODE_PRIVATE)
return get(sharedPreferences)
}
@@ -0,0 +1,133 @@
package com.geode.launcher.utils
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
private const val PROFILES_FILE_KEY = "GeodeLauncherProfiles"
private const val PROFILE_LIST_SAVE_KEY = "profiles"
private const val PROFILE_CURRENT_SAVE_KEY = "currentProfile"
@Serializable
data class Profile(val path: String, val name: String)
class ProfileManager {
private val applicationContext: Context
private val preferences: SharedPreferences
companion object {
private lateinit var managerInstance: ProfileManager
fun get(context: Context): ProfileManager {
if (!Companion::managerInstance.isInitialized) {
val applicationContext = context.applicationContext
managerInstance = ProfileManager(applicationContext)
}
return managerInstance
}
}
constructor(applicationContext: Context) {
this.applicationContext = applicationContext
this.preferences = applicationContext.getSharedPreferences(PROFILES_FILE_KEY, Context.MODE_PRIVATE)
_storedProfiles.value = getProfiles()
}
private val _storedProfiles = MutableStateFlow<List<Profile>>(emptyList())
val storedProfiles = _storedProfiles.asStateFlow()
fun getCurrentProfile(): String? {
return preferences.getString(PROFILE_CURRENT_SAVE_KEY, null)
.takeUnless { it.isNullOrEmpty() }
}
fun getCurrentFileKey(): String {
val currentProfile = getCurrentProfile()
?: return FILE_KEY
return "${FILE_KEY}_${currentProfile}"
}
fun getProfile(path: String): Profile? {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null)
return profilesSet?.firstNotNullOfOrNull {
Json.decodeFromString<Profile>(it).takeIf { it.path == path }
}
}
/**
* Store a profile into the save, removing any duplicate profiles beforehand
*/
fun storeProfile(profile: Profile) {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
val removedList = profilesSet.filterTo(LinkedHashSet()) {
val saveProfile = Json.decodeFromString<Profile>(it)
saveProfile.path != profile.path
}
removedList.add(Json.encodeToString(profile))
preferences.edit {
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
}
_storedProfiles.value = getProfiles()
}
fun deleteProfile(path: String) {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
val removedList = profilesSet.filterTo(LinkedHashSet()) {
val saveProfile = Json.decodeFromString<Profile>(it)
saveProfile.path != path
}
preferences.edit {
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
}
_storedProfiles.value = getProfiles()
}
fun deleteProfiles(paths: List<String>) {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
val removedList = profilesSet.filterTo(LinkedHashSet()) {
val saveProfile = Json.decodeFromString<Profile>(it)
!paths.contains(saveProfile.path)
}
preferences.edit(commit = true) {
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
}
_storedProfiles.value = getProfiles()
}
fun setCurrentProfile(profile: String?) {
preferences.edit(commit = true) {
if (profile.isNullOrEmpty()) {
remove(PROFILE_CURRENT_SAVE_KEY)
} else {
putString(PROFILE_CURRENT_SAVE_KEY, profile)
}
}
}
fun getProfiles(): List<Profile> {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
return profilesSet.map {
Json.decodeFromString<Profile>(it)
}
}
}