ThemingRoot.kt (18734B) - raw


      1 package me.rhunk.snapenhance.ui.manager.pages.theming
      2 
      3 import androidx.compose.foundation.ExperimentalFoundationApi
      4 import androidx.compose.foundation.clickable
      5 import androidx.compose.foundation.layout.*
      6 import androidx.compose.foundation.lazy.LazyColumn
      7 import androidx.compose.foundation.lazy.items
      8 import androidx.compose.foundation.pager.HorizontalPager
      9 import androidx.compose.foundation.pager.rememberPagerState
     10 import androidx.compose.material.icons.Icons
     11 import androidx.compose.material.icons.filled.*
     12 import androidx.compose.material3.*
     13 import androidx.compose.runtime.*
     14 import androidx.compose.ui.Alignment
     15 import androidx.compose.ui.Modifier
     16 import androidx.compose.ui.focus.FocusRequester
     17 import androidx.compose.ui.focus.focusRequester
     18 import androidx.compose.ui.layout.onGloballyPositioned
     19 import androidx.compose.ui.text.font.FontWeight
     20 import androidx.compose.ui.text.style.TextAlign
     21 import androidx.compose.ui.text.style.TextOverflow
     22 import androidx.compose.ui.unit.dp
     23 import androidx.compose.ui.unit.sp
     24 import androidx.core.net.toUri
     25 import androidx.navigation.NavBackStackEntry
     26 import kotlinx.coroutines.Dispatchers
     27 import kotlinx.coroutines.launch
     28 import kotlinx.coroutines.withContext
     29 import me.rhunk.snapenhance.common.data.DatabaseTheme
     30 import me.rhunk.snapenhance.common.data.DatabaseThemeContent
     31 import me.rhunk.snapenhance.common.data.ExportedTheme
     32 import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
     33 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
     34 import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
     35 import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
     36 import me.rhunk.snapenhance.storage.*
     37 import me.rhunk.snapenhance.ui.manager.Routes
     38 import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
     39 import me.rhunk.snapenhance.ui.util.openFile
     40 import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
     41 import me.rhunk.snapenhance.ui.util.saveFile
     42 import okhttp3.OkHttpClient
     43 
     44 class ThemingRoot: Routes.Route() {
     45     val localReloadDispatcher = AsyncUpdateDispatcher()
     46     private lateinit var activityLauncherHelper: ActivityLauncherHelper
     47 
     48     private var currentPage by mutableIntStateOf(0)
     49     val okHttpClient by lazy { OkHttpClient() }
     50     val searchFilter = mutableStateOf("")
     51 
     52 
     53     private fun exportTheme(theme: DatabaseTheme) {
     54         context.coroutineScope.launch {
     55             val exportedTheme = theme.toExportedTheme(context.database.getThemeContent(theme.id) ?: DatabaseThemeContent())
     56 
     57             activityLauncherHelper.saveFile(theme.name.replace(" ", "_").lowercase() + ".json") { uri ->
     58                 runCatching {
     59                     context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
     60                         outputStream.write(context.gson.toJson(exportedTheme).toByteArray())
     61                         outputStream.flush()
     62                     }
     63                     context.shortToast("Theme exported successfully")
     64                 }.onFailure {
     65                     context.log.error("Failed to save theme", it)
     66                     context.longToast("Failed to export theme! Check logs for more details")
     67                 }
     68             }
     69         }
     70     }
     71 
     72     private fun duplicateTheme(theme: DatabaseTheme) {
     73         context.coroutineScope.launch {
     74             val themeId = context.database.addOrUpdateTheme(theme.copy(
     75                 updateUrl = null
     76             ))
     77             context.database.setThemeContent(themeId, context.database.getThemeContent(theme.id) ?: DatabaseThemeContent())
     78             context.shortToast("Theme duplicated successfully")
     79             withContext(Dispatchers.Main) {
     80                 localReloadDispatcher.dispatch()
     81             }
     82         }
     83     }
     84 
     85     suspend fun importTheme(content: String, updateUrl: String? = null) {
     86         val theme = context.gson.fromJson(content, ExportedTheme::class.java)
     87         val existingTheme = updateUrl?.let {
     88             context.database.getThemeIdByUpdateUrl(it)
     89         }?.let {
     90             context.database.getThemeInfo(it)
     91         }
     92         val databaseTheme = theme.toDatabaseTheme(
     93             updateUrl = updateUrl,
     94             enabled = existingTheme?.enabled ?: false
     95         )
     96 
     97         val themeId = context.database.addOrUpdateTheme(
     98             themeId = existingTheme?.id,
     99             theme = databaseTheme
    100         )
    101 
    102         context.database.setThemeContent(themeId, theme.content)
    103         context.shortToast("Theme imported successfully")
    104         withContext(Dispatchers.Main) {
    105             localReloadDispatcher.dispatch()
    106         }
    107     }
    108 
    109     private fun importTheme() {
    110         activityLauncherHelper.openFile { uri ->
    111             context.coroutineScope.launch {
    112                 runCatching {
    113                     val themeJson = context.androidContext.contentResolver.openInputStream(uri.toUri())?.bufferedReader().use {
    114                         it?.readText()
    115                     } ?: throw Exception("Failed to read file")
    116 
    117                     importTheme(themeJson)
    118                 }.onFailure {
    119                     context.log.error("Failed to import theme", it)
    120                     context.longToast("Failed to import theme! Check logs for more details")
    121                 }
    122             }
    123         }
    124     }
    125 
    126     private suspend fun importFromURL(url: String) {
    127         val result = okHttpClient.newCall(
    128             okhttp3.Request.Builder()
    129                 .url(url)
    130                 .build()
    131         ).execute()
    132 
    133         if (!result.isSuccessful) {
    134             throw Exception("Failed to fetch theme from URL ${result.message}")
    135         }
    136 
    137         importTheme(result.body.string(), url)
    138     }
    139 
    140     override val init: () -> Unit = {
    141         activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
    142     }
    143 
    144     override val topBarActions: @Composable (RowScope.() -> Unit) = {
    145         var showSearchBar by remember { mutableStateOf(false) }
    146         val focusRequester = remember { FocusRequester() }
    147 
    148         Row(
    149             verticalAlignment = Alignment.CenterVertically
    150         ) {
    151             if (showSearchBar) {
    152                 OutlinedTextField(
    153                     value = searchFilter.value,
    154                     onValueChange = { searchFilter.value = it },
    155                     placeholder = { Text("Search") },
    156                     modifier = Modifier
    157                         .weight(1f)
    158                         .focusRequester(focusRequester)
    159                         .onGloballyPositioned {
    160                             focusRequester.requestFocus()
    161                         },
    162                     colors = transparentTextFieldColors()
    163                 )
    164                 DisposableEffect(Unit) {
    165                     onDispose {
    166                         searchFilter.value = ""
    167                     }
    168                 }
    169             }
    170             IconButton(onClick = {
    171                 showSearchBar = !showSearchBar
    172             }) {
    173                 Icon(if (showSearchBar) Icons.Default.Close else Icons.Default.Search, contentDescription = null)
    174             }
    175         }
    176     }
    177 
    178     override val floatingActionButton: @Composable () -> Unit = {
    179         var showImportFromUrlDialog by remember { mutableStateOf(false) }
    180 
    181         if (showImportFromUrlDialog) {
    182             var url by remember { mutableStateOf("") }
    183             var loading by remember { mutableStateOf(false) }
    184 
    185             AlertDialog(
    186                 onDismissRequest = { showImportFromUrlDialog = false },
    187                 title = { Text("Import theme from URL") },
    188                 text = {
    189                     val focusRequester = remember { FocusRequester() }
    190                     TextField(
    191                         value = url,
    192                         onValueChange = { url = it },
    193                         label = { Text("URL") },
    194                         modifier = Modifier
    195                             .fillMaxWidth()
    196                             .focusRequester(focusRequester)
    197                             .onGloballyPositioned {
    198                                 focusRequester.requestFocus()
    199                             }
    200                     )
    201                     LaunchedEffect(Unit) {
    202                         context.androidContext.getUrlFromClipboard()?.let {
    203                             url = it
    204                         }
    205                     }
    206                 },
    207                 confirmButton = {
    208                     Button(
    209                         enabled = url.isNotBlank() && !loading,
    210                         onClick = {
    211                             loading = true
    212                             context.coroutineScope.launch {
    213                                 runCatching {
    214                                     importFromURL(url)
    215                                     withContext(Dispatchers.Main) {
    216                                         showImportFromUrlDialog = false
    217                                     }
    218                                 }.onFailure {
    219                                     context.log.error("Failed to import theme", it)
    220                                     context.longToast("Failed to import theme! ${it.message}")
    221                                 }
    222                                 withContext(Dispatchers.Main) {
    223                                     loading = false
    224                                 }
    225                             }
    226                         },
    227                         modifier = Modifier.fillMaxWidth()
    228                     ) {
    229                         Text("Import")
    230                     }
    231                 }
    232             )
    233         }
    234         Column(
    235             horizontalAlignment = Alignment.End
    236         ) {
    237             when (currentPage) {
    238                 0 -> {
    239                     ExtendedFloatingActionButton(
    240                         onClick = {
    241                             routes.editTheme.navigate()
    242                         },
    243                         icon = {
    244                             Icon(Icons.Default.Add, contentDescription = null)
    245                         },
    246                         text = {
    247                             Text("New theme")
    248                         }
    249                     )
    250                     Spacer(modifier = Modifier.height(8.dp))
    251                     ExtendedFloatingActionButton(
    252                         onClick = {
    253                             importTheme()
    254                         },
    255                         icon = {
    256                             Icon(Icons.Default.Upload, contentDescription = null)
    257                         },
    258                         text = {
    259                             Text("Import from file")
    260                         }
    261                     )
    262                     Spacer(modifier = Modifier.height(8.dp))
    263                     ExtendedFloatingActionButton(
    264                         onClick = { showImportFromUrlDialog = true },
    265                         icon = {
    266                             Icon(Icons.Default.Link, contentDescription = null)
    267                         },
    268                         text = {
    269                             Text("Import from URL")
    270                         }
    271                     )
    272                 }
    273                 1 -> {
    274                     ExtendedFloatingActionButton(
    275                         onClick = {
    276                             routes.manageRepos.navigate()
    277                         },
    278                         icon = {
    279                             Icon(Icons.Default.Public, contentDescription = null)
    280                         },
    281                         text = {
    282                             Text("Manage repositories")
    283                         }
    284                     )
    285                 }
    286             }
    287         }
    288     }
    289 
    290     @Composable
    291     private fun InstalledThemes() {
    292         val themes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = localReloadDispatcher, keys = arrayOf(searchFilter.value)) {
    293             context.database.getThemeList().let {
    294                 val filter = searchFilter.value
    295                 if (filter.isNotBlank()) {
    296                     it.filter { theme ->
    297                         theme.name.contains(filter, ignoreCase = true) ||
    298                         theme.author?.contains(filter, ignoreCase = true) == true ||
    299                         theme.description?.contains(filter, ignoreCase = true) == true
    300                     }
    301                 } else it
    302             }
    303         }
    304 
    305         LazyColumn(
    306             modifier = Modifier
    307                 .fillMaxSize(),
    308             contentPadding = PaddingValues(8.dp),
    309             verticalArrangement = Arrangement.spacedBy(8.dp)
    310         ) {
    311             item {
    312                 if (themes.isEmpty()) {
    313                     Text(
    314                         text = translation["no_themes_hint"],
    315                         modifier = Modifier
    316                             .padding(16.dp)
    317                             .fillMaxWidth(),
    318                         textAlign = TextAlign.Center,
    319                         fontSize = 15.sp,
    320                         fontWeight = FontWeight.Light
    321                     )
    322                 }
    323             }
    324             items(themes, key = { it.id }) { theme ->
    325                 var showSettings by remember(theme) { mutableStateOf(false) }
    326 
    327                 ElevatedCard(
    328                     modifier = Modifier
    329                         .fillMaxWidth(),
    330                     onClick = {
    331                         routes.editTheme.navigate {
    332                             this["theme_id"] = theme.id.toString()
    333                         }
    334                     }
    335                 ) {
    336                     Row(
    337                         modifier = Modifier
    338                             .padding(8.dp)
    339                             .fillMaxWidth(),
    340                         verticalAlignment = Alignment.CenterVertically
    341                     ) {
    342                         Icon(
    343                             Icons.Default.Palette, contentDescription = null, modifier = Modifier.padding(5.dp))
    344                         Column(
    345                             modifier = Modifier
    346                                 .weight(1f)
    347                                 .padding(8.dp),
    348                         ) {
    349                             Text(text = theme.name, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 20.sp)
    350                             theme.author?.takeIf { it.isNotBlank() }?.let {
    351                                 Text(text = "by $it", lineHeight = 15.sp, fontWeight = FontWeight.Light, fontSize = 12.sp)
    352                             }
    353                         }
    354 
    355                         Row(
    356                             horizontalArrangement = Arrangement.spacedBy(5.dp),
    357                         ) {
    358                             var state by remember { mutableStateOf(theme.enabled) }
    359 
    360                             IconButton(onClick = {
    361                                 showSettings = true
    362                             }) {
    363                                 Icon(Icons.Default.Settings, contentDescription = null)
    364                             }
    365 
    366                             Switch(checked = state, onCheckedChange = {
    367                                 state = it
    368                                 context.database.setThemeState(theme.id, it)
    369                             })
    370                         }
    371                     }
    372                 }
    373 
    374                 if (showSettings) {
    375                     val actionsRow = remember {
    376                         mapOf(
    377                             ("Duplicate" to Icons.Default.ContentCopy) to { duplicateTheme(theme) },
    378                             ("Export" to Icons.Default.Download) to { exportTheme(theme) }
    379                         )
    380                     }
    381                     AlertDialog(
    382                         onDismissRequest = { showSettings = false },
    383                         title = { Text("Theme settings") },
    384                         text = {
    385                             Column(
    386                                 modifier = Modifier.fillMaxWidth(),
    387                             ) {
    388                                 actionsRow.forEach { entry ->
    389                                     Row(
    390                                         modifier = Modifier
    391                                             .fillMaxWidth()
    392                                             .clickable {
    393                                                 showSettings = false
    394                                                 entry.value()
    395                                             },
    396                                         verticalAlignment = Alignment.CenterVertically
    397                                     ) {
    398                                         Icon(entry.key.second, contentDescription = null, modifier = Modifier.padding(16.dp))
    399                                         Spacer(modifier = Modifier.width(5.dp))
    400                                         Text(entry.key.first)
    401                                     }
    402                                 }
    403                             }
    404                         },
    405                         confirmButton = {}
    406                     )
    407                 }
    408             }
    409             item {
    410                 Spacer(modifier = Modifier.height(200.dp))
    411             }
    412         }
    413     }
    414 
    415 
    416     @OptIn(ExperimentalFoundationApi::class)
    417     override val content: @Composable (NavBackStackEntry) -> Unit = {
    418         val coroutineScope = rememberCoroutineScope()
    419         val titles = remember { listOf("Installed Themes", "Catalog") }
    420         val pagerState = rememberPagerState { titles.size }
    421         currentPage = pagerState.currentPage
    422 
    423         Column {
    424             TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions ->
    425                 TabRowDefaults.SecondaryIndicator(
    426                     Modifier.pagerTabIndicatorOffset(
    427                         pagerState = pagerState,
    428                         tabPositions = tabPositions
    429                     )
    430                 )
    431             }) {
    432                 titles.forEachIndexed { index, title ->
    433                     Tab(
    434                         selected = pagerState.currentPage == index,
    435                         onClick = {
    436                             coroutineScope.launch {
    437                                 pagerState.animateScrollToPage(index)
    438                             }
    439                         },
    440                         text = {
    441                             Text(
    442                                 text = title,
    443                                 maxLines = 2,
    444                                 overflow = TextOverflow.Ellipsis
    445                             )
    446                         }
    447                     )
    448                 }
    449             }
    450 
    451             HorizontalPager(
    452                 modifier = Modifier.weight(1f),
    453                 state = pagerState
    454             ) { page ->
    455                 when (page) {
    456                     0 -> InstalledThemes()
    457                     1 -> ThemeCatalog(this@ThemingRoot)
    458                 }
    459             }
    460         }
    461     }
    462 }