diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1012efb56b6..3ff59515ed7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -161,7 +161,7 @@ android:supportsPictureInPicture="true" /> diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 253f1023766..4fbcc913d8f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -179,6 +179,7 @@ import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream4.compose.CloudStreamThemeAppSettings import com.lagradost.cloudstream4.theme.CloudStreamTheme import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job @@ -1301,7 +1302,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa composeView.apply { setContent { - CloudStreamTheme { + CloudStreamThemeAppSettings { MainActivityScreen.Content() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt index c88f70a7395..69881702d5c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -368,6 +368,11 @@ object AccountHelper { fun Activity?.showAccountSelectLinear() { val activity = this as? MainActivity ?: return + activity.navigate( + R.id.accountSelectActivity, + Bundle().apply { putBoolean(AccountSelectActivity2.IS_FROM_MAIN_ACTIVITY, true) } + ) + /* val viewModel = ViewModelProvider(activity)[AccountViewModel::class.java] val binding: AccountSelectLinearBinding = AccountSelectLinearBinding.inflate( @@ -425,6 +430,6 @@ object AccountHelper { val layoutManager = recyclerView.layoutManager as LinearLayoutManager layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) } - } + }*/ } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt new file mode 100644 index 00000000000..239ae84676c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt @@ -0,0 +1,845 @@ +package com.lagradost.cloudstream3.ui.account + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.Account +import com.lagradost.cloudstream4.compose.ActionDialog +import com.lagradost.cloudstream4.compose.BlackButton +import com.lagradost.cloudstream4.compose.BlackTextField +import com.lagradost.cloudstream4.compose.EMULATOR +import com.lagradost.cloudstream4.compose.PHONE +import com.lagradost.cloudstream4.compose.RoundedShape +import com.lagradost.cloudstream4.compose.TV +import com.lagradost.cloudstream4.compose.WhiteButton +import com.lagradost.cloudstream4.compose.circle +import com.lagradost.cloudstream4.compose.circleBorder +import com.lagradost.cloudstream4.compose.focusOutline +import com.lagradost.cloudstream4.compose.isLayout +import com.lagradost.cloudstream4.compose.rounded +import com.lagradost.cloudstream4.compose.whiteOutline +import com.lagradost.cloudstream4.state.SearchableData +import com.lagradost.cloudstream4.state.sortByInt +import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme +import com.mihon.material.padding +import com.mihon.presentation.settings.widget.SwitchPreferenceWidget +import com.mihon.presentation.settings.widget.TextPreferenceWidget +import kotlinx.collections.immutable.toPersistentMap + +@PreviewLightDark +@Composable +fun Preview() { + CloudStreamPreviewTheme { + AccountScreen.Content( + state = AccountState( + canEditAccount = false, + editAccount = Account( + keyIndex = 1, + name = "Hello world", + defaultImageIndex = 2, + lockPin = "1234" + ), + accounts = SearchableData.from( + data = + (0..7).associateWith { index -> + Account( + keyIndex = index, + name = "hello $index", + defaultImageIndex = index, + lockPin = if (index % 2 == 1) { + "1234" + } else { + null + } + ) + }.toPersistentMap(), + sortedBy = sortByInt { it.keyIndex }, + ), + ), onAction = {}) + } +} + +object AccountScreen { + @Composable + fun Content( + state: AccountState, + onAction: (AccountAction) -> Unit + ) { + if (state.editAccount != null) { + EditAccountDialog( + exists = state.accounts.data.containsKey(state.editAccount.keyIndex), + account = state.editAccount, + onAction = onAction + ) + } + + Scaffold { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + horizontalAlignment = Alignment.End + ) { + val (edit, accounts) = FocusRequester.createRefs() + + IconButton( + onClick = { + onAction(AccountAction.SetEditMode(!state.canEditAccount)) + }, modifier = Modifier + .focusRequester(edit) + .focusProperties { + down = accounts + }.focusOutline(shape = CircleShape) + ) { + Icon( + painter = if (state.canEditAccount) { + painterResource(R.drawable.close_24px) + } else { + painterResource(R.drawable.edit_24px) + }, + contentDescription = stringResource(R.string.manage_accounts) + ) + } + Column( + modifier = Modifier + .fillMaxSize().verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = if (state.canEditAccount) stringResource(R.string.manage_accounts) else stringResource( + R.string.select_an_account + ), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(MaterialTheme.padding.large) + ) + + AccountsRow( + state = state, onAction = onAction, + modifier = Modifier + .focusRequester(accounts) + .focusProperties { + up = edit + }) + + Spacer(modifier = Modifier.height(100.dp)) + } + } + } + } + + enum class WaitForDialogType { + RemoveLock, + NewPassword, + DeleteAccount, + NewProfileUrl, + NewProfilePicture, + Login, + } + + @Composable + fun WaitForDialog( + type: WaitForDialogType, + onDismissRequest: () -> Unit, + account: Account, + onAction: (AccountAction) -> Unit + ) { + when (type) { + WaitForDialogType.RemoveLock -> { + if (account.lockPin != null) { + EnterPinDialog( + expected = account.lockPin, + onDismissRequest = onDismissRequest, + onCorrect = { + onAction(AccountAction.EditAccount(account.copy(lockPin = null))) + } + ) + } + } + + WaitForDialogType.Login -> { + if (account.lockPin != null) { + EnterPinDialog( + expected = account.lockPin, + onDismissRequest = onDismissRequest, + onCorrect = { + onAction(AccountAction.SaveAccount(account)) + onAction(AccountAction.EditAccount(null)) + } + ) + } + } + + WaitForDialogType.NewPassword -> { + SetPinDialog( + onDismissRequest = onDismissRequest, + onSet = { newLockPin -> + onAction(AccountAction.EditAccount(account.copy(lockPin = newLockPin))) + }) + } + + WaitForDialogType.DeleteAccount -> { + ActionDialog( + title = stringResource(R.string.delete), + text = stringResource(R.string.delete_message, account.name), + confirm = { + onAction(AccountAction.DeleteAccount(account.keyIndex)) + onAction(AccountAction.EditAccount(null)) + onDismissRequest() + }, + dismiss = onDismissRequest, + confirmText = stringResource(R.string.delete), + dismissText = stringResource(R.string.cancel) + ) + } + + WaitForDialogType.NewProfilePicture -> { + SetPictureDialog( + account = account, + onDismissRequest = onDismissRequest, + onSet = { newImage -> + onAction( + AccountAction.EditAccount( + account.copy( + defaultImageIndex = newImage, + customImage = null + ) + ) + ) + onDismissRequest() + }) + } + + WaitForDialogType.NewProfileUrl -> { + SetUrlDialog(onDismissRequest = onDismissRequest, onSet = { newImage -> + onAction(AccountAction.EditAccount(account.copy(customImage = newImage))) + onDismissRequest() + }) + } + } + } + + @Composable + fun EditAccountDialog( + exists: Boolean, + account: Account, + onAction: (AccountAction) -> Unit + ) { + val selectFileSelector = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + // It lies, it can be null if file manager quits. + if (uri == null) return@rememberLauncherForActivityResult + val context = CloudStreamApp.context ?: return@rememberLauncherForActivityResult + + try { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + onAction(AccountAction.EditAccount(account = account.copy(customImage = uri.toString()))) + } catch (t: Throwable) { + logError(t) + } + } + + var pendingDialog by remember { mutableStateOf(null) } + pendingDialog?.let { waitType -> + WaitForDialog( + type = waitType, + onDismissRequest = { pendingDialog = null }, + account = account, + onAction = onAction + ) + } + + val (focusRequester) = FocusRequester.createRefs() + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + val isTv = isLayout(TV) + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + confirmButton = { + if (exists) { + WhiteButton(text = stringResource(R.string.delete)) { + pendingDialog = WaitForDialogType.DeleteAccount + } + } + WhiteButton(text = stringResource(R.string.sort_apply)) { + if (account.lockPin == null) { + onAction(AccountAction.SaveAccount(account)) + onAction(AccountAction.EditAccount(null)) + } else { + pendingDialog = WaitForDialogType.Login + } + } + }, + dismissButton = { + BlackButton( + text = stringResource(R.string.cancel), + modifier = Modifier.focusRequester(focusRequester) + ) { + onAction(AccountAction.EditAccount(null)) + } + }, + onDismissRequest = { + /** + * For UX reasons we can only dismiss it with the cancel button, + * but does not have those issues + * */ + if (isTv) { + onAction(AccountAction.EditAccount(null)) + } + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + AccountImage( + selected = false, + edit = false, + account = account, + onClick = { + onAction( + AccountAction.EditAccount( + account.copy( + customImage = null, + defaultImageIndex = (account.defaultImageIndex + 1) % DataStoreHelper.profileImages.size + ) + ) + ) + }, + onLongClick = {}) + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + + BlackTextField( + modifier = Modifier.fillMaxWidth(), + value = account.name, + onValueChange = { value -> + onAction(AccountAction.EditAccount(account.copy(name = value))) + }, + placeHolder = stringResource(R.string.name) + ) + + var selectProfileImage by remember { mutableStateOf(false) } + + + TextPreferenceWidget( + modifier = Modifier + .rounded() + .focusOutline(), + title = stringResource(R.string.edit_profile_image_title), + widget = { + Icon( + painter = painterResource(R.drawable.add_photo_alternate_24px), + contentDescription = null + ) + DropdownMenu(expanded = selectProfileImage, onDismissRequest = { + selectProfileImage = false + }, shape = RoundedShape()) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.edit_profile_image_url)) }, + onClick = { + pendingDialog = WaitForDialogType.NewProfileUrl + selectProfileImage = false + } + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.edit_profile_image_picker)) }, + onClick = { + selectFileSelector.launch(arrayOf("image/*")) + selectProfileImage = false + } + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.edit_profile_image_default)) }, + onClick = { + pendingDialog = WaitForDialogType.NewProfilePicture + selectProfileImage = false + } + ) + } + }, + onPreferenceClick = { + selectProfileImage = true + } + ) + + if (account.keyIndex != 0) { + SwitchPreferenceWidget( + modifier = Modifier + .rounded() + .focusOutline(), + title = stringResource(R.string.lock_profile), + checked = account.lockPin != null, + onCheckedChanged = { + pendingDialog = if (account.lockPin == null) { + WaitForDialogType.NewPassword + } else { + WaitForDialogType.RemoveLock + } + }) + } + + } + } + ) + } + + + @Composable + fun AccountsRow( + state: AccountState, + onAction: (AccountAction) -> Unit, + modifier: Modifier = Modifier, + ) { + FlowRow( + modifier = modifier.focusGroup(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.padding.medium, + alignment = Alignment.Start + ), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.padding.medium, + alignment = Alignment.CenterVertically + ), + ) { + state.accounts.sorted.forEach { key -> + val account = state.accounts.data[key]!! + Account( + edit = state.canEditAccount, + account = account, + onAction = onAction, + selected = state.lastLoginKeyIndex == account.keyIndex + ) + } + + var hasFocus by remember { mutableStateOf(false) } + val size = 100.dp + val innerPadding by animateDpAsState( + targetValue = if (hasFocus || isLayout(PHONE or EMULATOR)) 0.dp else MaterialTheme.padding.extraSmall, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(size) + .onFocusChanged { newFocus -> + hasFocus = newFocus.hasFocus + } + ) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .clickable(interactionSource = interactionSource, indication = null) { + onAction(AccountAction.NewAccount) + } + .size(size) + .padding(innerPadding) + .border( + 2.dp, + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f), + CircleShape + ) + .circle() + .background(MaterialTheme.colorScheme.surfaceVariant) + .whiteOutline(hasFocus = hasFocus, shape = CircleShape) + .indication( + interactionSource = interactionSource, + indication = ripple() + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.add_24px), + contentDescription = stringResource(R.string.add_account), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(36.dp) + ) + } + Spacer(Modifier.height(MaterialTheme.padding.small)) + Text( + text = stringResource(R.string.add_account), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } + } + } + + @Composable + fun PinTextField(value: String, onValueChange: (String) -> Unit) { + BlackTextField( + value = value, keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + keyboardType = KeyboardType.NumberPassword, + ), placeHolder = stringResource(R.string.pin), onValueChange = onValueChange + ) + } + + @Composable + fun EnterPinDialog(expected: String, onDismissRequest: (() -> Unit), onCorrect: (() -> Unit)) { + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = onDismissRequest, + confirmButton = {}, + dismissButton = { + BlackButton(text = stringResource(R.string.cancel), onClick = onDismissRequest) + }, + text = { + var inputText by remember { mutableStateOf("") } + var hasError by remember { mutableStateOf(false) } + Column { + PinTextField( + value = inputText, + onValueChange = { newText -> + if (newText.length == expected.length) { + if (newText == expected) { + onCorrect() + onDismissRequest() + } else { + inputText = "" + hasError = true + } + } else { + inputText = newText + } + }, + ) + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + if (hasError) { + Text(text = stringResource(R.string.pin_error_incorrect)) + } + } + }, + title = { + Text(text = stringResource(R.string.enter_pin)) + }) + } + + @Composable + fun SetPinDialog(onDismissRequest: (() -> Unit), onSet: ((String) -> Unit)) { + var inputText by remember { mutableStateOf("") } + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = onDismissRequest, + confirmButton = { + if (inputText.length == 4) { + WhiteButton( + text = stringResource(R.string.sort_apply), + onClick = { + onSet(inputText) + onDismissRequest() + }) + } + }, + dismissButton = { + BlackButton(text = stringResource(R.string.cancel), onClick = onDismissRequest) + }, + text = { + PinTextField( + value = inputText, + onValueChange = { newText -> + inputText = if (newText.length <= 4) { + newText + } else { + newText.substring(0, 4) + } + }, + ) + }, + title = { + Text(text = stringResource(R.string.enter_pin)) + }) + } + + @Composable + fun SetUrlDialog(onDismissRequest: (() -> Unit), onSet: ((String) -> Unit)) { + var inputText by remember { mutableStateOf("") } + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = onDismissRequest, + confirmButton = { + WhiteButton( + text = stringResource(R.string.sort_apply), + onClick = { + onSet(inputText) + onDismissRequest() + }) + }, + dismissButton = { + BlackButton(text = stringResource(R.string.cancel), onClick = onDismissRequest) + }, + text = { + BlackTextField( + value = inputText, + onValueChange = { newText -> + inputText = newText + }, + placeHolder = stringResource(R.string.edit_profile_image_hint) + ) + }, + title = { + Text(text = stringResource(R.string.edit_profile_image_title)) + }) + } + + @Composable + fun SetPictureDialog( + account: Account, + onDismissRequest: (() -> Unit), + onSet: ((Int) -> Unit) + ) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = onDismissRequest, + confirmButton = { }, + dismissButton = { + BlackButton(text = stringResource(R.string.cancel), onClick = onDismissRequest) + }, + text = { + FlowRow( + modifier = Modifier.focusGroup(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.padding.medium, + alignment = Alignment.Start + ), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.padding.medium, + alignment = Alignment.CenterVertically + ), + ) { + for (i in DataStoreHelper.profileImages.indices) { + AccountImage( + edit = false, + showName = false, + selected = account.defaultImageIndex == i, + account = account.copy( + customImage = null, + defaultImageIndex = i, + lockPin = null + ), + onClick = { + onSet(i) + }, + onLongClick = {}) + } + } + }, + title = { + Text(text = stringResource(R.string.edit_profile_image_title)) + }) + } + + @Composable + fun AccountImage( + /** If we should show the edit pen */ + edit: Boolean, + /** If this element should have focus */ + selected: Boolean, + /** If we should show the name under the picture */ + showName: Boolean = true, + account: Account, + onClick: () -> Unit, + onLongClick: () -> Unit + ) { + val image = account.customImage + ?: DataStoreHelper.profileImages[account.defaultImageIndex % DataStoreHelper.profileImages.size] + + val interactionSource = remember { MutableInteractionSource() } + var hasFocus by remember { mutableStateOf(false) } + val size = 100.dp + val innerPadding by animateDpAsState( + targetValue = if (hasFocus || isLayout(PHONE or EMULATOR)) 0.dp else MaterialTheme.padding.extraSmall, + ) + val (focusRequester) = FocusRequester.createRefs() + LaunchedEffect(Unit) { + if (selected) { + focusRequester.requestFocus() + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(size) + .focusRequester(focusRequester) + .onFocusChanged { newFocus -> + hasFocus = newFocus.hasFocus + } + ) { + Box( + modifier = Modifier + .size(size) + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + onLongClick = onLongClick + ) + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = image, + modifier = Modifier + .circleBorder(size) + .whiteOutline(hasFocus = hasFocus, shape = CircleShape) + .indication(interactionSource = interactionSource, indication = ripple()), + contentDescription = account.name, + contentScale = ContentScale.Crop, + alpha = if (edit) { + 0.6f + } else { + 1.0f + } + ) + + if (account.lockPin != null) { + Box( + contentAlignment = Alignment.TopEnd, modifier = Modifier + .fillMaxSize() + .padding( + MaterialTheme.padding.extraSmall + ) + ) { + Icon( + painter = painterResource(R.drawable.video_locked), + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + .size(22.dp + MaterialTheme.padding.extraSmall * 2) + .background( + MaterialTheme.colorScheme.surfaceVariant, + shape = CircleShape + ) + .padding(MaterialTheme.padding.extraSmall) + ) + } + } + + this@Column.AnimatedVisibility( + visible = edit, + enter = fadeIn(), + exit = fadeOut() + ) { + Icon( + painter = painterResource(R.drawable.edit_24px), + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(36.dp), + ) + } + } + if (showName) { + Spacer(Modifier.height(MaterialTheme.padding.small)) + Text( + text = account.name, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + } + } + + @Composable + fun Account( + edit: Boolean, + selected: Boolean, + account: Account, + onAction: (AccountAction) -> Unit + ) { + var waitForLock by remember { mutableStateOf(false) } + if (waitForLock && account.lockPin != null) { + EnterPinDialog( + expected = account.lockPin, + onDismissRequest = { + waitForLock = false + }, onCorrect = { + onAction(AccountAction.LoginWithAccount(account.keyIndex)) + }) + } + + AccountImage(selected = selected, edit = edit, account = account, onClick = { + if (edit) { + onAction(AccountAction.EditAccount(account)) + } else if (account.lockPin != null) { + waitForLock = true + } else { + onAction(AccountAction.LoginWithAccount(account.keyIndex)) + } + }, onLongClick = { + /** Long click -> edit without having to press the edit button for better UX */ + onAction(AccountAction.EditAccount(account)) + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity2.kt new file mode 100644 index 00000000000..61052f4e722 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity2.kt @@ -0,0 +1,180 @@ +package com.lagradost.cloudstream3.ui.account + +import android.annotation.SuppressLint +import android.os.Bundle +import android.util.Log +import androidx.activity.compose.setContent +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewmodel.compose.viewModel +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.CommonActivity.loadThemes +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.BiometricAuthenticator +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.Account +import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat +import com.lagradost.cloudstream3.utils.UIHelper.openActivity +import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.compose.CloudStreamThemeAppSettings +import com.lagradost.cloudstream4.state.SearchableData +import com.lagradost.cloudstream4.state.sortByInt +import kotlinx.collections.immutable.persistentMapOf + +class AccountSelectActivity2 : FragmentActivity(), BiometricCallback { + companion object { + var hasLoggedIn: Boolean = false + const val IS_FROM_MAIN_ACTIVITY = "isFromMainActivity" + const val IS_EDITING_FROM_MAIN_ACTIVITY = "isEditingFromMainActivity" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Are we editing and coming from MainActivity? + val isEditingFromMainActivity = intent.getBooleanExtra(IS_EDITING_FROM_MAIN_ACTIVITY, false) + + // Are we invoked from the main activity to edit it? + val isFromMainActivity = intent.getBooleanExtra(IS_FROM_MAIN_ACTIVITY, false) + + /** + * Sometimes we start this activity when we have already logged in + * For example when using cloudstreamsearch:// + * + * In those cases we want to just go to the main activity instantly + **/ + if (hasLoggedIn && !isEditingFromMainActivity && !isFromMainActivity) { + navigateToMainActivity() + return + } + + /** + * Here we set the themes, this *can* be deferred for faster instant login, + * but has some consequences that makes it undesirable even if it is slower + * + * CommonActivity.init : loads the correct locale for default_account + * loadThemes : loads the correct color on the toast when logging in + * */ + CommonActivity.init(this) + loadThemes(this) + enableEdgeToEdgeCompat() + setNavigationBarColorCompat(R.attr.primaryBlackBackground) + + /** + * To avoid touching the UI layer, we use the raw state before the viewmodel is created + * to enable us to skip account selection but still use the ergonomics of AccountState + * */ + val defaultName = getString(R.string.default_account) + val defaultAccount = Account( + keyIndex = 0, + name = defaultName, + defaultImageIndex = 0, + ) + val initialState = AccountState( + canEditAccount = isEditingFromMainActivity, + lastLoginKeyIndex = DataStoreHelper.selectedKeyIndex, + accounts = SearchableData.from( + /** We must always have a default account + * even if DataStoreHelper.accounts.size = 0 */ + data = persistentMapOf(defaultAccount.keyIndex to defaultAccount) + .puttingAll(DataStoreHelper.accounts.associateBy { it.keyIndex }), + sortedBy = sortByInt { it.keyIndex } + ) + ) + + val settings = AppSettings(this) + val skipAccountSelection = settings.security.skipAccountSelection.get() + + /** Don't show account selection if there is only one account that exists */ + if ( + !isFromMainActivity && + !isEditingFromMainActivity && + (skipAccountSelection || initialState.accounts.data.size == 1) + ) { + /** We must have an account without a pin */ + val currentAccount = initialState.accounts.data[initialState.lastLoginKeyIndex] + if ( + currentAccount != null && + currentAccount.lockPin == null + ) { + /** + * Show toast if we have many accounts, + * as it is unnecessary for 1 account + * */ + DataStoreHelper.setAccount( + currentAccount, + showToast = initialState.accounts.data.size > 1 + ) + /** + * We avoid using the viewmodel Login as we + * can return *before* we load Compose code! + * */ + navigateToMainActivity() + return + } + } + + /** + * If this app is locked with biometrics, + * then require that on a startup that is not skipped! + * */ + if ( + !isFromMainActivity && + !isEditingFromMainActivity && + isLayout(PHONE) && + settings.security.biometrics.get() && + deviceHasPasswordPinLock(this) + ) { + startBiometricAuthentication( + this, + R.string.biometric_authentication_title, + false + ) + promptInfo?.let { prompt -> + biometricPrompt?.authenticate(prompt) + } + } + + setContent { + val viewModel = viewModel { + AccountViewModel2( + defaultAccount = defaultAccount, + initialState = initialState + ) + } + val state by viewModel.state.collectAsState() + if (state.loggedInWith != null) { + navigateToMainActivity() + } + CloudStreamThemeAppSettings { + AccountScreen.Content(state, viewModel::onAction) + } + } + } + + @SuppressLint("UnsafeIntentLaunch") + private fun navigateToMainActivity() { + hasLoggedIn = true + // We want to propagate any intent we get here to MainActivity since this is just an intermediary + openActivity(MainActivity::class.java, baseIntent = intent) + finish() // Finish the account selection activity + } + + override fun onAuthenticationSuccess() { + Log.i(BiometricAuthenticator.TAG, "Authentication successful in AccountSelectActivity") + } + + override fun onAuthenticationError() { + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel2.kt new file mode 100644 index 00000000000..6f51c8e7716 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel2.kt @@ -0,0 +1,140 @@ +package com.lagradost.cloudstream3.ui.account + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream4.state.ActionHandler +import com.lagradost.cloudstream4.state.DefaultStateContainer +import com.lagradost.cloudstream4.state.SearchableData +import com.lagradost.cloudstream4.state.StateContainer +import com.lagradost.cloudstream4.state.sortByInt +import kotlinx.collections.immutable.persistentMapOf +import kotlin.random.Random +import kotlin.random.nextInt + +@Immutable +data class AccountState( + /** Mostly used for focus stuff on TV */ + val lastLoginKeyIndex : Int = 0, + + /** Is currently open to editing */ + val canEditAccount: Boolean = false, + + /** keyIndex -> DataStoreHelper.Account sorted by keyIndex as keyIndex is unique and in add order */ + val accounts: SearchableData = SearchableData.from( + data = persistentMapOf(), + /** This will cause bugs if changed! */ + sortedBy = sortByInt { it.keyIndex } + ), + + /** + * We are currectly editing this account with this value, it may be an existing/new account + * non-null = editing, null = not editing + * */ + val editAccount: DataStoreHelper.Account? = null, + + /** We are currently logged in with this account, we use state instead of effect to prevent bugs */ + val loggedInWith: Int? = null +) + +@Immutable +sealed class AccountAction { + data class SetEditMode(val canEditAccount: Boolean) : AccountAction() + object NewAccount : AccountAction() + data class EditAccount(val account: DataStoreHelper.Account?) : AccountAction() + data class SaveAccount(val account: DataStoreHelper.Account) : AccountAction() + data class LoginWithAccount(val accountKeyIndex: Int) : AccountAction() + data class DeleteAccount(val accountKeyIndex: Int) : AccountAction() +} + +class AccountViewModel2( + val defaultAccount: DataStoreHelper.Account, + initialState : AccountState, +) : ViewModel(), StateContainer by DefaultStateContainer(initialState), ActionHandler { + fun updateAccount( + updater: (SearchableData) -> SearchableData + ) { + updateState { + /** `adding` has will "Replace" on same key, and "Add" on a new key */ + val newAccounts = updater(accounts) + DataStoreHelper.accounts = + Array(newAccounts.sorted.size) { i -> + /** i -> keyIndex -> Account; As keyIndex != index */ + newAccounts.data[newAccounts.sorted[i]]!! + } + copy(accounts = newAccounts) + } + + MainActivity.reloadAccountEvent(true) + } + + override fun onAction(action: AccountAction) { + when (action) { + is AccountAction.SetEditMode -> { + updateState { copy(canEditAccount = action.canEditAccount) } + } + + is AccountAction.NewAccount -> { + updateState { + copy( + editAccount = DataStoreHelper.Account( + /** Last => maxOf, this WILL break if we allow other sorting orders */ + keyIndex = (accounts.sorted.lastOrNull() ?: 0) + 1, + /** This is not "translated", but that should not really be a problem */ + name = "Account ${accounts.data.size + 1}", + customImage = null, + defaultImageIndex = Random.nextInt(DataStoreHelper.profileImages.indices), + lockPin = null, + ) + ) + } + } + + is AccountAction.DeleteAccount -> { + removeKeys(action.accountKeyIndex.toString()) + + /** If we remove the logged in account, + * then we switch to the default account */ + if (action.accountKeyIndex == DataStoreHelper.selectedKeyIndex) { + val nextAccount = state.value.accounts.data[defaultAccount.keyIndex] + /** This should never be non-null, but just in case! */ + ?: defaultAccount + DataStoreHelper.setAccount(nextAccount) + updateState { copy(lastLoginKeyIndex = nextAccount.keyIndex) } + } + + updateAccount { accounts -> + val pendingAccounts = accounts.removing(action.accountKeyIndex) + + /** We must always have a default account */ + if (pendingAccounts.data.containsKey(defaultAccount.keyIndex)) { + pendingAccounts + } else { + pendingAccounts.adding(defaultAccount.keyIndex, defaultAccount) + } + } + } + + is AccountAction.SaveAccount -> { + updateAccount { accounts -> + /** `adding` has will "Replace" on same key, and "Add" on a new key */ + accounts.adding(action.account.keyIndex, action.account) + } + } + + is AccountAction.LoginWithAccount -> { + val accounts = state.value.accounts + val loginAccount = accounts.data[action.accountKeyIndex] ?: return + // Show toast if we have many accounts, as it is unnecessary for 1 account + DataStoreHelper.setAccount(loginAccount, showToast = accounts.data.size > 1) + updateState { copy(loggedInWith = loginAccount.keyIndex, lastLoginKeyIndex = loginAccount.keyIndex) } + } + + is AccountAction.EditAccount -> { + updateState { copy(editAccount = action.account) } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index eabac20d322..d15ea791bee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.utils import android.content.Context +import androidx.compose.runtime.Immutable import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.unixTimeMS @@ -163,6 +164,7 @@ object DataStoreHelper { } @Serializable + @Immutable data class Account( @JsonProperty("keyIndex") @SerialName("keyIndex") val keyIndex: Int, @JsonProperty("name") @SerialName("name") val name: String, @@ -201,11 +203,13 @@ object DataStoreHelper { } } - fun setAccount(account: Account) { + fun setAccount(account: Account, showToast : Boolean = true) { val homepage = currentHomePage selectedKeyIndex = account.keyIndex AccountManager.updateAccountIds() - showToast(context?.getString(R.string.logged_account, account.name) ?: account.name) + if(showToast) { + showToast(context?.getString(R.string.logged_account, account.name) ?: account.name) + } MainActivity.bookmarksUpdatedEvent(true) MainActivity.reloadLibraryEvent(true) val oldAccount = accounts.find { it.keyIndex == account.keyIndex } diff --git a/app/src/main/res/drawable/add_24px.xml b/app/src/main/res/drawable/add_24px.xml new file mode 100644 index 00000000000..fe955f0ab44 --- /dev/null +++ b/app/src/main/res/drawable/add_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/add_photo_alternate_24px.xml b/app/src/main/res/drawable/add_photo_alternate_24px.xml new file mode 100644 index 00000000000..de281a3c09e --- /dev/null +++ b/app/src/main/res/drawable/add_photo_alternate_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/edit_24px.xml b/app/src/main/res/drawable/edit_24px.xml new file mode 100644 index 00000000000..4d1d11fac40 --- /dev/null +++ b/app/src/main/res/drawable/edit_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index dd700d9fc54..d60abc203dd 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -691,7 +691,7 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b1c332d85cb..f050ad99354 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -776,6 +776,9 @@ LongPress Speed Toggle Hold to get 2x speed Edit Profile Image + Url + Image picker + Default images Enter Profile Image URL No URL Found Invalid URL or Image diff --git a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/ComposeFragment.kt b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/ComposeFragment.kt index 9a91e3e1688..8d450203e0a 100644 --- a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/ComposeFragment.kt +++ b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/ComposeFragment.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView @@ -24,28 +25,32 @@ fun Screen.createComposeView( savedInstanceState: Bundle?, ): View = ComposeView(inflater.context).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val settings = rememberAppSettings() - val mode by settings.ui.theme.collectAsState() - val primaryColor by settings.ui.primaryColor.collectAsState() - val layout by settings.ui.layout.collectAsState() - val layoutFlag = DeviceLayout.layoutToFlag(LocalContext.current, layout) + CloudStreamThemeAppSettings(this@createComposeView::Content) + } +} - CloudStreamTheme( - mode = perfToMode(mode), - primaryColor = perfToColor(primaryColor), - ) { - val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) { - "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner" - }.onBackPressedDispatcher +@Composable +fun CloudStreamThemeAppSettings(content: @Composable () -> Unit) { + val settings = rememberAppSettings() + val mode by settings.ui.theme.collectAsState() + val primaryColor by settings.ui.primaryColor.collectAsState() + val layout by settings.ui.layout.collectAsState() + val layoutFlag = DeviceLayout.layoutToFlag(LocalContext.current, layout) - CompositionLocalProvider( - LocalBackPress provides backDispatcher::onBackPressed, - DeviceLayout.LocalLayout provides layoutFlag - ) { - this@createComposeView.Content() - } + CloudStreamTheme( + mode = perfToMode(mode), + primaryColor = perfToColor(primaryColor), + ) { + val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) { + "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner" + }.onBackPressedDispatcher + + CompositionLocalProvider( + LocalBackPress provides backDispatcher::onBackPressed, + DeviceLayout.LocalLayout provides layoutFlag + ) { + content() } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt index b5a491ef5ff..c15c66b2392 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream4.compose import androidx.compose.material3.ButtonColors import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SelectableChipColors +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color @@ -42,4 +43,17 @@ object Colors { selectedLeadingIconColor = MaterialTheme.colorScheme.background, selectedTrailingIconColor = MaterialTheme.colorScheme.background, ) + internal val blackTextField @Composable get() = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onBackground, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onBackground, + focusedTrailingIconColor = MaterialTheme.colorScheme.onBackground, + focusedLeadingIconColor = MaterialTheme.colorScheme.onBackground, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + cursorColor = MaterialTheme.colorScheme.onBackground, + ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt new file mode 100644 index 00000000000..81f42167196 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream4.compose + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun BlackTextField( + value: String, + placeHolder : String, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + onValueChange: (String) -> Unit, +) { + TextField( + modifier = modifier.focusOutline(), + value = value, + onValueChange = onValueChange, + keyboardOptions = keyboardOptions, + placeholder = { + Text(text = placeHolder) + }, + shape = RoundedShape(), + colors = Colors.blackTextField, + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt index 25668090016..c20f2a16a97 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt @@ -68,7 +68,7 @@ data class FilterByQuery( } } -/** Default sorting by name (Ascending A->Z), use `reversed()` if Z->A is desired */ +/** Default sorting by name (Ascending A->Z), use `rev()` if Z->A is desired */ @Immutable data class SortByName( val ignoreCase: Boolean = true, @@ -82,6 +82,30 @@ data class SortByName( } } +fun sortByLong(keyExtractor: ((T) -> Long)): Comparator { + return Comparator { c1: T, c2: T -> + keyExtractor(c1).compareTo(keyExtractor(c2)) + } +} + +fun sortByFloat(keyExtractor: ((T) -> Float)): Comparator { + return Comparator { c1: T, c2: T -> + keyExtractor(c1).compareTo(keyExtractor(c2)) + } +} + +fun sortByInt(keyExtractor: ((T) -> Int)): Comparator { + return Comparator { c1: T, c2: T -> + keyExtractor(c1).compareTo(keyExtractor(c2)) + } +} + +fun Comparator.rev(): Comparator { + return Comparator { c1: T, c2: T -> + this@rev.compare(c2, c1) + } +} + /** Helper function to apply updater directly to a specific item instead of doing the get put manually */ fun PersistentMap.edit(key: K, updater: V.() -> V): PersistentMap { val value = get(key) ?: return this