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 }