FeaturesRootSection.kt (29935B) - raw


      1 package me.rhunk.snapenhance.ui.manager.pages.features
      2 
      3 import android.content.Intent
      4 import android.net.Uri
      5 import androidx.compose.animation.AnimatedContentTransitionScope
      6 import androidx.compose.animation.core.tween
      7 import androidx.compose.foundation.background
      8 import androidx.compose.foundation.clickable
      9 import androidx.compose.foundation.layout.*
     10 import androidx.compose.foundation.lazy.LazyColumn
     11 import androidx.compose.foundation.lazy.items
     12 import androidx.compose.foundation.shape.RoundedCornerShape
     13 import androidx.compose.foundation.text.KeyboardActions
     14 import androidx.compose.material.icons.Icons
     15 import androidx.compose.material.icons.automirrored.filled.OpenInNew
     16 import androidx.compose.material.icons.filled.*
     17 import androidx.compose.material3.*
     18 import androidx.compose.runtime.*
     19 import androidx.compose.ui.Alignment
     20 import androidx.compose.ui.Modifier
     21 import androidx.compose.ui.focus.FocusRequester
     22 import androidx.compose.ui.focus.focusRequester
     23 import androidx.compose.ui.graphics.Color
     24 import androidx.compose.ui.graphics.graphicsLayer
     25 import androidx.compose.ui.text.font.FontWeight
     26 import androidx.compose.ui.text.style.TextOverflow
     27 import androidx.compose.ui.unit.dp
     28 import androidx.compose.ui.unit.sp
     29 import androidx.lifecycle.Lifecycle
     30 import androidx.navigation.NavBackStackEntry
     31 import androidx.navigation.NavGraph.Companion.findStartDestination
     32 import androidx.navigation.NavGraphBuilder
     33 import androidx.navigation.NavOptions
     34 import androidx.navigation.compose.composable
     35 import kotlinx.coroutines.Dispatchers
     36 import kotlinx.coroutines.Job
     37 import kotlinx.coroutines.delay
     38 import kotlinx.coroutines.launch
     39 import me.rhunk.snapenhance.common.config.*
     40 import me.rhunk.snapenhance.common.ui.TopBarActionButton
     41 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
     42 import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
     43 import me.rhunk.snapenhance.ui.manager.MainActivity
     44 import me.rhunk.snapenhance.ui.manager.Routes
     45 import me.rhunk.snapenhance.ui.util.*
     46 
     47 class FeaturesRootSection : Routes.Route() {
     48     private val alertDialogs by lazy { AlertDialogs(context.translation) }
     49 
     50     companion object {
     51         const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}"
     52         const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}"
     53     }
     54 
     55     private var activityLauncherHelper: ActivityLauncherHelper? = null
     56 
     57     private val allContainers by lazy {
     58         val containers = mutableMapOf<String, PropertyPair<*>>()
     59         fun queryContainerRecursive(container: ConfigContainer) {
     60             container.properties.forEach {
     61                 if (it.key.dataType.type == DataProcessors.Type.CONTAINER) {
     62                     containers[it.key.name] = PropertyPair(it.key, it.value)
     63                     queryContainerRecursive(it.value.get() as ConfigContainer)
     64                 }
     65             }
     66         }
     67         queryContainerRecursive(context.config.root)
     68         containers
     69     }
     70 
     71     private val allProperties by lazy {
     72         val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>()
     73         allContainers.values.forEach {
     74             val container = it.value.get() as ConfigContainer
     75             container.properties.forEach { property ->
     76                 properties[property.key] = property.value
     77             }
     78         }
     79         properties
     80     }
     81 
     82     private fun navigateToMainRoot() {
     83         routes.navController.navigate(routeInfo.id, NavOptions.Builder()
     84             .setPopUpTo(routes.navController.graph.findStartDestination().id, false)
     85             .setLaunchSingleTop(true)
     86             .build()
     87         )
     88     }
     89 
     90     override val init: () -> Unit = {
     91         activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
     92     }
     93 
     94     private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) {
     95         activityLauncherHelper?.let(block) ?: run {
     96             //open manager if activity launcher is null
     97             val intent = Intent(context.androidContext, MainActivity::class.java)
     98             intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
     99             intent.putExtra("route", routeInfo.id)
    100             context.androidContext.startActivity(intent)
    101         }
    102     }
    103 
    104     override val content: @Composable (NavBackStackEntry) -> Unit = {
    105         Container(context.config.root)
    106     }
    107 
    108     override val customComposables: NavGraphBuilder.() -> Unit = {
    109         routeInfo.childIds.addAll(listOf(FEATURE_CONTAINER_ROUTE, SEARCH_FEATURE_ROUTE))
    110 
    111         composable(FEATURE_CONTAINER_ROUTE, enterTransition = {
    112             slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100))
    113         }, exitTransition = {
    114             slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300))
    115         }) { backStackEntry ->
    116             backStackEntry.arguments?.getString("name")?.let { containerName ->
    117                 allContainers[containerName]?.let {
    118                     Container(it.value.get() as ConfigContainer)
    119                 }
    120             }
    121         }
    122 
    123         composable(SEARCH_FEATURE_ROUTE) { backStackEntry ->
    124             backStackEntry.arguments?.getString("keyword")?.let { keyword ->
    125                 val properties = allProperties.filter {
    126                     it.key.name.contains(keyword, ignoreCase = true) ||
    127                             context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
    128                             context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
    129                 }.map { PropertyPair(it.key, it.value) }
    130 
    131                 PropertiesView(properties)
    132             }
    133         }
    134     }
    135 
    136     @Composable
    137     private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) {
    138         var showDialog by remember { mutableStateOf(false) }
    139         var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
    140 
    141         fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true }
    142 
    143         if (showDialog) {
    144             Dialog(
    145                 properties = DialogProperties(
    146                     usePlatformDefaultWidth = false
    147                 ),
    148                 onDismissRequest = { showDialog = false },
    149             ) {
    150                 dialogComposable()
    151             }
    152         }
    153 
    154         val propertyValue = property.value
    155 
    156         if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
    157             registerDialogOnClickCallback()
    158             dialogComposable = {
    159                 var isEmpty by remember { mutableStateOf(false) }
    160                 val files = rememberAsyncMutableStateList(defaultValue = listOf()) {
    161                     context.fileHandleManager.getStoredFiles {
    162                         property.key.params.filenameFilter?.invoke(it.name) == true
    163                     }.also {
    164                         isEmpty = it.isEmpty()
    165                         if (isEmpty) {
    166                             propertyValue.setAny(null)
    167                         }
    168                     }
    169                 }
    170                 var selectedFile by remember(files.size) { mutableStateOf(files.firstOrNull { it.name == propertyValue.getNullable() }.also {
    171                     if (files.isNotEmpty() && it == null) propertyValue.setAny(null)
    172                 }?.name) }
    173 
    174                 Card(
    175                     shape = MaterialTheme.shapes.large,
    176                     modifier = Modifier
    177                         .fillMaxWidth(),
    178                 ) {
    179                     LazyColumn(
    180                         modifier = Modifier
    181                             .fillMaxWidth()
    182                             .padding(4.dp),
    183                     ) {
    184                         item {
    185                             Column(
    186                                 modifier = Modifier
    187                                     .fillMaxWidth()
    188                                     .padding(16.dp),
    189                                 horizontalAlignment = Alignment.CenterHorizontally
    190                             ) {
    191                                 Text(
    192                                     text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"],
    193                                     fontSize = 18.sp,
    194                                     fontWeight = FontWeight.Bold,
    195                                 )
    196                                 if (isEmpty) {
    197                                     Text(
    198                                         text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"],
    199                                         fontSize = 16.sp,
    200                                         modifier = Modifier.padding(top = 10.dp),
    201                                     )
    202                                 }
    203                             }
    204                         }
    205                         items(files, key = { it.name }) { file ->
    206                             Row(
    207                                 modifier = Modifier
    208                                     .clickable {
    209                                         selectedFile =
    210                                             if (selectedFile == file.name) null else file.name
    211                                         propertyValue.setAny(selectedFile)
    212                                     }
    213                                     .padding(5.dp),
    214                                 verticalAlignment = Alignment.CenterVertically
    215                             ) {
    216                                 Icon(Icons.Filled.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp))
    217                                 Text(
    218                                     text = file.name,
    219                                     modifier = Modifier
    220                                         .padding(3.dp)
    221                                         .weight(1f),
    222                                     fontSize = 14.sp,
    223                                     lineHeight = 16.sp
    224                                 )
    225                                 if (selectedFile == file.name) {
    226                                     Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(5.dp))
    227                                 }
    228                             }
    229                         }
    230                     }
    231                 }
    232             }
    233 
    234             Icon(Icons.Filled.AttachFile, contentDescription = null)
    235             return
    236         }
    237 
    238         if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
    239             IconButton(onClick = registerClickCallback {
    240                 activityLauncher {
    241                     chooseFolder { uri ->
    242                         propertyValue.setAny(uri)
    243                     }
    244                 }
    245             }.let { { it.invoke(true) } }) {
    246                 Icon(Icons.Filled.FolderOpen, contentDescription = null)
    247             }
    248             return
    249         }
    250 
    251         when (val dataType = remember { property.key.dataType.type }) {
    252             DataProcessors.Type.BOOLEAN -> {
    253                 var state by remember { mutableStateOf(propertyValue.get() as Boolean) }
    254                 Switch(
    255                     checked = state,
    256                     onCheckedChange = registerClickCallback {
    257                         state = state.not()
    258                         propertyValue.setAny(state)
    259                     }
    260                 )
    261             }
    262 
    263             DataProcessors.Type.MAP_COORDINATES -> {
    264                 registerDialogOnClickCallback()
    265                 dialogComposable = {
    266                     alertDialogs.ChooseLocationDialog(property) {
    267                         showDialog = false
    268                     }
    269                 }
    270 
    271                 Text(
    272                     overflow = TextOverflow.Ellipsis,
    273                     maxLines = 1,
    274                     modifier = Modifier.widthIn(0.dp, 120.dp),
    275                     text = (propertyValue.get() as Pair<*, *>).let {
    276                         "${it.first.toString().toFloatOrNull() ?: 0F}, ${it.second.toString().toFloatOrNull() ?: 0F}"
    277                     }
    278                 )
    279             }
    280 
    281             DataProcessors.Type.STRING_UNIQUE_SELECTION -> {
    282                 registerDialogOnClickCallback()
    283 
    284                 dialogComposable = {
    285                     alertDialogs.UniqueSelectionDialog(property)
    286                 }
    287 
    288                 Text(
    289                     overflow = TextOverflow.Ellipsis,
    290                     maxLines = 1,
    291                     modifier = Modifier.widthIn(0.dp, 120.dp),
    292                     text = (propertyValue.getNullable() as? String ?: "null").let {
    293                         property.key.propertyOption(context.translation, it)
    294                     }
    295                 )
    296             }
    297 
    298             DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
    299                 dialogComposable = {
    300                     when (dataType) {
    301                         DataProcessors.Type.STRING_MULTIPLE_SELECTION -> {
    302                             alertDialogs.MultipleSelectionDialog(property)
    303                         }
    304                         DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
    305                             alertDialogs.KeyboardInputDialog(property) { showDialog = false }
    306                         }
    307                         else -> {}
    308                     }
    309                 }
    310 
    311                 registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
    312                     if (dataType == DataProcessors.Type.INTEGER ||
    313                         dataType == DataProcessors.Type.FLOAT) {
    314                         FilledIconButton(onClick = it) {
    315                             Text(
    316                                 text = propertyValue.get().toString(),
    317                                 modifier = Modifier.wrapContentWidth(),
    318                                 overflow = TextOverflow.Ellipsis
    319                             )
    320                         }
    321                     } else {
    322                         IconButton(onClick = it) {
    323                             Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
    324                         }
    325                     }
    326                 }
    327             }
    328 
    329             DataProcessors.Type.INT_COLOR -> {
    330                 dialogComposable = {
    331                     alertDialogs.ColorPickerPropertyDialog(property) {
    332                         showDialog = false
    333                     }
    334                 }
    335 
    336                 registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
    337                     CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) })
    338                 }
    339             }
    340 
    341             DataProcessors.Type.CONTAINER -> {
    342                 val container = propertyValue.get() as ConfigContainer
    343 
    344                 registerClickCallback {
    345                     routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name))
    346                 }
    347 
    348                 if (!container.hasGlobalState) return
    349 
    350                 var state by remember { mutableStateOf(container.globalState ?: false) }
    351 
    352                 Box(
    353                     modifier = Modifier
    354                         .padding(end = 15.dp),
    355                 ) {
    356 
    357                     Box(modifier = Modifier
    358                         .height(50.dp)
    359                         .width(1.dp)
    360                         .background(
    361                             color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
    362                             shape = RoundedCornerShape(5.dp)
    363                         ))
    364                 }
    365 
    366                 Switch(
    367                     checked = state,
    368                     onCheckedChange = {
    369                         state = state.not()
    370                         container.globalState = state
    371                     }
    372                 )
    373             }
    374         }
    375 
    376     }
    377 
    378     @Composable
    379     private fun PropertyCard(property: PropertyPair<*>) {
    380         var clickCallback by remember { mutableStateOf<ClickCallback?>(null) }
    381         val noticeColorMap = mapOf(
    382             FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87),
    383             FeatureNotice.BAN_RISK.key to Color(0xFFFF8585),
    384             FeatureNotice.INTERNAL_BEHAVIOR.key to Color(0xFFFFFB87),
    385         )
    386 
    387         val versionCheck = remember { property.key.params.versionCheck }
    388         val versionCheckPair = remember(property) { versionCheck?.checkVersion(context.installationSummary.snapchatInfo?.versionCode ?: return@remember null)}
    389         val isComponentDisabled = remember { versionCheckPair != null && versionCheck?.isDisabled == true }
    390 
    391         ElevatedCard(
    392             modifier = Modifier
    393                 .fillMaxWidth()
    394                 .then(
    395                     if (isComponentDisabled) Modifier.graphicsLayer(alpha = 0.5f)
    396                     else Modifier
    397                 )
    398                 .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp)
    399         ) {
    400             Row(
    401                 modifier = Modifier
    402                     .fillMaxSize()
    403                     .clickable {
    404                         clickCallback?.invoke(true)
    405                     }
    406                     .padding(all = 4.dp),
    407                 horizontalArrangement = Arrangement.SpaceBetween
    408             ) {
    409                 property.key.params.icon?.let { icon ->
    410                     Icon(
    411                         imageVector = icon,
    412                         contentDescription = null,
    413                         modifier = Modifier
    414                             .align(Alignment.CenterVertically)
    415                             .padding(start = 10.dp)
    416                     )
    417                 }
    418 
    419                 Column(
    420                     modifier = Modifier
    421                         .align(Alignment.CenterVertically)
    422                         .weight(1f, fill = true)
    423                         .padding(all = 10.dp)
    424                 ) {
    425                     Text(
    426                         text = context.translation[property.key.propertyName()],
    427                         fontSize = 16.sp,
    428                         lineHeight = 16.sp,
    429                         fontWeight = FontWeight.Bold
    430                     )
    431                     Text(
    432                         text = context.translation[property.key.propertyDescription()],
    433                         fontSize = 12.sp,
    434                         lineHeight = 15.sp
    435                     )
    436                     property.key.params.notices.also {
    437                         if (it.isNotEmpty()) Spacer(modifier = Modifier.height(5.dp))
    438                     }.forEach {
    439                         Text(
    440                             text = context.translation["features.notices.${it.key}"],
    441                             color = noticeColorMap[it.key] ?: Color(0xFFFFFB87),
    442                             fontSize = 12.sp,
    443                             lineHeight = 15.sp
    444                         )
    445                     }
    446 
    447                     if (versionCheckPair != null) {
    448                         Spacer(modifier = Modifier.height(2.dp))
    449                         Text(
    450                             text = context.translation.format(
    451                                 "manager.sections.features.${versionCheckPair.second.key}",
    452                                 "version" to versionCheckPair.first.first
    453                             ),
    454                             color = Color(0xFFFF8585),
    455                             fontSize = 12.sp,
    456                             lineHeight = 15.sp
    457                         )
    458                     }
    459                 }
    460 
    461                 Row(
    462                     modifier = Modifier
    463                         .align(Alignment.CenterVertically)
    464                         .padding(all = 10.dp),
    465                     verticalAlignment = Alignment.CenterVertically
    466                 ) {
    467                     PropertyAction(property, registerClickCallback = { callback ->
    468                         if (property.key.propertyTranslationPath().startsWith("rules.properties")) {
    469                             clickCallback = {
    470                                 routes.manageRuleFeature.navigate {
    471                                     put("rule_type", property.key.name)
    472                                 }
    473                             }
    474                             return@PropertyAction clickCallback!!
    475                         }
    476                         clickCallback = callback
    477                         callback
    478                     })
    479                 }
    480             }
    481         }
    482     }
    483 
    484     @Composable
    485     private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) {
    486         var searchValue by remember { mutableStateOf("") }
    487         val scope = rememberCoroutineScope()
    488         var currentSearchJob by remember { mutableStateOf<Job?>(null) }
    489 
    490         rowScope.apply {
    491             TextField(
    492                 value = searchValue,
    493                 onValueChange = { keyword ->
    494                     searchValue = keyword
    495                     if (keyword.isEmpty()) {
    496                         navigateToMainRoot()
    497                         return@TextField
    498                     }
    499                     currentSearchJob?.cancel()
    500                     scope.launch {
    501                         delay(300)
    502                         routes.navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder()
    503                             .setLaunchSingleTop(true)
    504                             .setPopUpTo(routeInfo.id, false)
    505                             .build()
    506                         )
    507                     }.also { currentSearchJob = it }
    508                 },
    509 
    510                 keyboardActions = KeyboardActions(onDone = {
    511                     focusRequester.freeFocus()
    512                 }),
    513                 modifier = Modifier
    514                     .focusRequester(focusRequester)
    515                     .weight(1f, fill = true)
    516                     .padding(end = 10.dp)
    517                     .height(70.dp),
    518                 singleLine = true,
    519                 colors = transparentTextFieldColors()
    520             )
    521         }
    522     }
    523 
    524     override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
    525         var showSearchBar by remember { mutableStateOf(false) }
    526         val focusRequester = remember { FocusRequester() }
    527 
    528         if (showSearchBar) {
    529             FeatureSearchBar(this, focusRequester)
    530             LaunchedEffect(true) {
    531                 focusRequester.requestFocus()
    532             }
    533         }
    534 
    535 
    536         if (showSearchBar) {
    537             IconButton(onClick = {
    538                 showSearchBar = false
    539                 if (routes.currentDestination == SEARCH_FEATURE_ROUTE) {
    540                     navigateToMainRoot()
    541                 }
    542             }) {
    543                 Icon(
    544                     imageVector = Icons.Filled.Close,
    545                     contentDescription = null
    546                 )
    547             }
    548         } else {
    549             TopBarActionButton(
    550                 onClick = {
    551                     showSearchBar = true
    552                 },
    553                 icon = Icons.Filled.Search,
    554                 text = translation["search_button"]
    555             )
    556         }
    557 
    558         if (showSearchBar) return@topBarActions
    559 
    560         var showExportDropdownMenu by remember { mutableStateOf(false) }
    561         var showResetConfirmationDialog by remember { mutableStateOf(false) }
    562         var showExportDialog by remember { mutableStateOf(false) }
    563 
    564         if (showResetConfirmationDialog) {
    565             AlertDialog(
    566                 title = { Text(text = context.translation["manager.dialogs.reset_config.title"]) },
    567                 text = { Text(text = context.translation["manager.dialogs.reset_config.content"]) },
    568                 onDismissRequest = { showResetConfirmationDialog = false },
    569                 confirmButton = {
    570                     Button(
    571                         onClick = {
    572                             context.config.reset()
    573                             context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"])
    574                             showResetConfirmationDialog = false
    575                         }
    576                     ) {
    577                         Text(text = context.translation["button.positive"])
    578                     }
    579                 },
    580                 dismissButton = {
    581                     Button(
    582                         onClick = {
    583                             showResetConfirmationDialog = false
    584                         }
    585                     ) {
    586                         Text(text = context.translation["button.negative"])
    587                     }
    588                 }
    589             )
    590         }
    591 
    592         if (showExportDialog) {
    593             fun exportConfig(
    594                 exportSensitiveData: Boolean
    595             ) {
    596                 showExportDialog = false
    597                 activityLauncher {
    598                     saveFile("config.json", "application/json") { uri ->
    599                         runCatching {
    600                             context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
    601                                 context.config.writeConfig()
    602                                 context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
    603                                 context.shortToast(translation["config_export_success_toast"])
    604                             }
    605                         }.onFailure {
    606                             context.longToast(translation.format("config_export_failure_toast", "error" to it.message.toString()))
    607                         }
    608                     }
    609                 }
    610             }
    611 
    612             AlertDialog(
    613                 title = { Text(text = context.translation["manager.dialogs.export_config.title"]) },
    614                 text = { Text(text = context.translation["manager.dialogs.export_config.content"]) },
    615                 onDismissRequest = { showExportDialog = false },
    616                 confirmButton = {
    617                     Button(
    618                         onClick = { exportConfig(true) }
    619                     ) {
    620                         Text(text = context.translation["button.positive"])
    621                     }
    622                 },
    623                 dismissButton = {
    624                     Button(
    625                         onClick = { exportConfig(false) }
    626                     ) {
    627                         Text(text = context.translation["button.negative"])
    628                     }
    629                 }
    630             )
    631         }
    632 
    633         val actions = remember {
    634             mapOf(
    635                 translation["export_option"] to { showExportDialog = true },
    636                 translation["import_option"] to {
    637                     activityLauncher {
    638                         openFile("application/json") { uri ->
    639                             context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use {
    640                                 runCatching {
    641                                     context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8))
    642                                 }.onFailure {
    643                                     context.longToast(translation.format("config_import_failure_toast", "error" to it.message.toString()))
    644                                     return@use
    645                                 }
    646                                 context.shortToast(translation["config_import_success_toast"])
    647                                 context.coroutineScope.launch(Dispatchers.Main) {
    648                                     navigateReload()
    649                                 }
    650                             }
    651                         }
    652                     }
    653                 },
    654                 translation["reset_option"] to { showResetConfirmationDialog = true }
    655             )
    656         }
    657 
    658         if (context.activity != null) {
    659             IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) {
    660                 Icon(
    661                     imageVector = Icons.Filled.MoreVert,
    662                     contentDescription = null
    663                 )
    664             }
    665         }
    666 
    667         if (showExportDropdownMenu) {
    668             DropdownMenu(expanded = true, onDismissRequest = { showExportDropdownMenu = false }) {
    669                 actions.forEach { (name, action) ->
    670                     DropdownMenuItem(
    671                         text = {
    672                             Text(text = name)
    673                         },
    674                         onClick = {
    675                             action()
    676                             showExportDropdownMenu = false
    677                         }
    678                     )
    679                 }
    680             }
    681         }
    682     }
    683 
    684     @Composable
    685     private fun PropertiesView(
    686         properties: List<PropertyPair<*>>
    687     ) {
    688         Scaffold(
    689             modifier = Modifier.fillMaxSize(),
    690             content = { innerPadding ->
    691                 LazyColumn(
    692                     modifier = Modifier
    693                         .fillMaxHeight()
    694                         .padding(innerPadding),
    695                     //save button space
    696                     contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp),
    697                     verticalArrangement = Arrangement.Top
    698                 ) {
    699                     items(properties, key = { it.key.propertyName() }) {
    700                         PropertyCard(it)
    701                     }
    702                 }
    703             }
    704         )
    705     }
    706 
    707     override val floatingActionButton: @Composable () -> Unit = {
    708         fun saveConfig() {
    709             context.coroutineScope.launch(Dispatchers.IO) {
    710                 context.config.writeConfig()
    711                 context.log.verbose("saved config!")
    712             }
    713         }
    714 
    715         OnLifecycleEvent { _, event ->
    716             if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
    717                 saveConfig()
    718             }
    719         }
    720 
    721         DisposableEffect(Unit) {
    722             onDispose {
    723                 saveConfig()
    724             }
    725         }
    726     }
    727 
    728 
    729     @Composable
    730     private fun Container(
    731         configContainer: ConfigContainer
    732     ) {
    733         PropertiesView(remember {
    734             configContainer.properties.map { PropertyPair(it.key, it.value) }
    735         })
    736     }
    737 }