mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-29 04:50:15 +02:00
feat: catalog
Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
parent
2cb3db042f
commit
f9974b0e84
@ -93,11 +93,15 @@ class AppDatabase(
|
|||||||
"id INTEGER PRIMARY KEY AUTOINCREMENT",
|
"id INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||||
"enabled BOOLEAN DEFAULT 0",
|
"enabled BOOLEAN DEFAULT 0",
|
||||||
"name VARCHAR",
|
"name VARCHAR",
|
||||||
|
"description TEXT",
|
||||||
"version VARCHAR",
|
"version VARCHAR",
|
||||||
"author VARCHAR",
|
"author VARCHAR",
|
||||||
"updateUrl VARCHAR",
|
"updateUrl VARCHAR",
|
||||||
"content TEXT",
|
"content TEXT",
|
||||||
),
|
),
|
||||||
|
"repositories" to listOf(
|
||||||
|
"url VARCHAR PRIMARY KEY",
|
||||||
|
),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package me.rhunk.snapenhance.storage
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
|
||||||
|
|
||||||
|
|
||||||
|
fun AppDatabase.getRepositories(): List<String> {
|
||||||
|
return runBlocking(executor.asCoroutineDispatcher()) {
|
||||||
|
database.rawQuery("SELECT url FROM repositories", null).use { cursor ->
|
||||||
|
val repos = mutableListOf<String>()
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
repos.add(cursor.getStringOrNull("url") ?: continue)
|
||||||
|
}
|
||||||
|
repos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AppDatabase.removeRepo(url: String) {
|
||||||
|
runBlocking(executor.asCoroutineDispatcher()) {
|
||||||
|
database.delete("repositories", "url = ?", arrayOf(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AppDatabase.addRepo(url: String) {
|
||||||
|
runBlocking(executor.asCoroutineDispatcher()) {
|
||||||
|
database.insert("repositories", null, ContentValues().apply {
|
||||||
|
put("url", url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ fun AppDatabase.getThemeList(): List<DatabaseTheme> {
|
|||||||
id = cursor.getIntOrNull("id") ?: continue,
|
id = cursor.getIntOrNull("id") ?: continue,
|
||||||
enabled = cursor.getIntOrNull("enabled") == 1,
|
enabled = cursor.getIntOrNull("enabled") == 1,
|
||||||
name = cursor.getStringOrNull("name") ?: continue,
|
name = cursor.getStringOrNull("name") ?: continue,
|
||||||
|
description = cursor.getStringOrNull("description"),
|
||||||
version = cursor.getStringOrNull("version"),
|
version = cursor.getStringOrNull("version"),
|
||||||
author = cursor.getStringOrNull("author"),
|
author = cursor.getStringOrNull("author"),
|
||||||
updateUrl = cursor.getStringOrNull("updateUrl")
|
updateUrl = cursor.getStringOrNull("updateUrl")
|
||||||
@ -38,6 +39,7 @@ fun AppDatabase.getThemeInfo(id: Int): DatabaseTheme? {
|
|||||||
id = cursor.getIntOrNull("id") ?: return@use null,
|
id = cursor.getIntOrNull("id") ?: return@use null,
|
||||||
enabled = cursor.getIntOrNull("enabled") == 1,
|
enabled = cursor.getIntOrNull("enabled") == 1,
|
||||||
name = cursor.getStringOrNull("name") ?: return@use null,
|
name = cursor.getStringOrNull("name") ?: return@use null,
|
||||||
|
description = cursor.getStringOrNull("description"),
|
||||||
version = cursor.getStringOrNull("version"),
|
version = cursor.getStringOrNull("version"),
|
||||||
author = cursor.getStringOrNull("author"),
|
author = cursor.getStringOrNull("author"),
|
||||||
updateUrl = cursor.getStringOrNull("updateUrl")
|
updateUrl = cursor.getStringOrNull("updateUrl")
|
||||||
@ -46,11 +48,21 @@ fun AppDatabase.getThemeInfo(id: Int): DatabaseTheme? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun AppDatabase.getThemeIdByUpdateUrl(updateUrl: String): Int? {
|
||||||
|
return runBlocking(executor.asCoroutineDispatcher()) {
|
||||||
|
database.rawQuery("SELECT id FROM themes WHERE updateUrl = ?", arrayOf(updateUrl)).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return@use null
|
||||||
|
cursor.getIntOrNull("id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun AppDatabase.addOrUpdateTheme(theme: DatabaseTheme, themeId: Int? = null): Int {
|
fun AppDatabase.addOrUpdateTheme(theme: DatabaseTheme, themeId: Int? = null): Int {
|
||||||
return runBlocking(executor.asCoroutineDispatcher()) {
|
return runBlocking(executor.asCoroutineDispatcher()) {
|
||||||
val contentValues = ContentValues().apply {
|
val contentValues = ContentValues().apply {
|
||||||
put("enabled", if (theme.enabled) 1 else 0)
|
put("enabled", if (theme.enabled) 1 else 0)
|
||||||
put("name", theme.name)
|
put("name", theme.name)
|
||||||
|
put("description", theme.description)
|
||||||
put("version", theme.version)
|
put("version", theme.version)
|
||||||
put("author", theme.author)
|
put("author", theme.author)
|
||||||
put("updateUrl", theme.updateUrl)
|
put("updateUrl", theme.updateUrl)
|
||||||
|
@ -25,6 +25,7 @@ import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope
|
|||||||
import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview
|
import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview
|
||||||
import me.rhunk.snapenhance.ui.manager.pages.social.SocialRootSection
|
import me.rhunk.snapenhance.ui.manager.pages.social.SocialRootSection
|
||||||
import me.rhunk.snapenhance.ui.manager.pages.theming.EditThemeSection
|
import me.rhunk.snapenhance.ui.manager.pages.theming.EditThemeSection
|
||||||
|
import me.rhunk.snapenhance.ui.manager.pages.ManageReposSection
|
||||||
import me.rhunk.snapenhance.ui.manager.pages.theming.ThemingRoot
|
import me.rhunk.snapenhance.ui.manager.pages.theming.ThemingRoot
|
||||||
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
|
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
|
||||||
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
|
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
|
||||||
@ -62,6 +63,7 @@ class Routes(
|
|||||||
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
|
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
|
||||||
val theming = route(RouteInfo("theming"), ThemingRoot()).parent(home)
|
val theming = route(RouteInfo("theming"), ThemingRoot()).parent(home)
|
||||||
val editTheme = route(RouteInfo("edit_theme/?theme_id={theme_id}"), EditThemeSection())
|
val editTheme = route(RouteInfo("edit_theme/?theme_id={theme_id}"), EditThemeSection())
|
||||||
|
val manageRepos = route(RouteInfo("manage_repos"), ManageReposSection())
|
||||||
|
|
||||||
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRootSection())
|
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRootSection())
|
||||||
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
|
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
|
||||||
|
@ -0,0 +1,187 @@
|
|||||||
|
package me.rhunk.snapenhance.ui.manager.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.rhunk.snapenhance.common.data.RepositoryIndex
|
||||||
|
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
|
||||||
|
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
|
||||||
|
import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
|
||||||
|
import me.rhunk.snapenhance.storage.addRepo
|
||||||
|
import me.rhunk.snapenhance.storage.getRepositories
|
||||||
|
import me.rhunk.snapenhance.storage.removeRepo
|
||||||
|
import me.rhunk.snapenhance.ui.manager.Routes
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
class ManageReposSection: Routes.Route() {
|
||||||
|
private val updateDispatcher = AsyncUpdateDispatcher()
|
||||||
|
private val okHttpClient by lazy { OkHttpClient() }
|
||||||
|
|
||||||
|
override val floatingActionButton: @Composable () -> Unit = {
|
||||||
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
|
ExtendedFloatingActionButton(onClick = {
|
||||||
|
showAddDialog = true
|
||||||
|
}) {
|
||||||
|
Text("Add Repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAddDialog) {
|
||||||
|
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||||
|
|
||||||
|
suspend fun addRepo(url: String) {
|
||||||
|
var modifiedUrl = url;
|
||||||
|
|
||||||
|
if (url.startsWith("https://github.com/")) {
|
||||||
|
val splitUrl = modifiedUrl.removePrefix("https://github.com/").split("/")
|
||||||
|
val repoName = splitUrl[0] + "/" + splitUrl[1]
|
||||||
|
// fetch default branch
|
||||||
|
okHttpClient.newCall(
|
||||||
|
okhttp3.Request.Builder().url("https://api.github.com/repos/$repoName").build()
|
||||||
|
).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw Exception("Failed to fetch default branch: ${response.code}")
|
||||||
|
}
|
||||||
|
val json = response.body.string()
|
||||||
|
val defaultBranch = context.gson.fromJson(json, Map::class.java)["default_branch"] as String
|
||||||
|
context.log.info("Default branch for $repoName is $defaultBranch")
|
||||||
|
modifiedUrl = "https://raw.githubusercontent.com/$repoName/$defaultBranch/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val indexUri = modifiedUrl.toUri().buildUpon().appendPath("index.json").build()
|
||||||
|
okHttpClient.newCall(
|
||||||
|
okhttp3.Request.Builder().url(indexUri.toString()).build()
|
||||||
|
).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw Exception("Failed to fetch index from $indexUri: ${response.code}")
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val repoIndex = context.gson.fromJson(response.body.charStream(), RepositoryIndex::class.java).also {
|
||||||
|
context.log.info("repository index: $it")
|
||||||
|
}
|
||||||
|
|
||||||
|
context.database.addRepo(modifiedUrl)
|
||||||
|
context.shortToast("Repository added successfully! $repoIndex")
|
||||||
|
showAddDialog = false
|
||||||
|
updateDispatcher.dispatch()
|
||||||
|
}.onFailure {
|
||||||
|
throw Exception("Failed to parse index from $indexUri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
var loading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(onDismissRequest = {
|
||||||
|
showAddDialog = false
|
||||||
|
}, title = {
|
||||||
|
Text("Add Repository URL")
|
||||||
|
}, text = {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onGloballyPositioned {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
value = url,
|
||||||
|
onValueChange = {
|
||||||
|
url = it
|
||||||
|
}, label = {
|
||||||
|
Text("Repository URL")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
context.androidContext.getUrlFromClipboard()?.let {
|
||||||
|
url = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, confirmButton = {
|
||||||
|
Button(
|
||||||
|
enabled = !loading,
|
||||||
|
onClick = {
|
||||||
|
loading = true;
|
||||||
|
coroutineScope.launch {
|
||||||
|
runCatching {
|
||||||
|
addRepo(url)
|
||||||
|
}.onFailure {
|
||||||
|
context.log.error("Failed to add repository", it)
|
||||||
|
context.shortToast("Failed to add repository: ${it.message}")
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
} else {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) {
|
||||||
|
context.database.getRepositories()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
if (repositories.isEmpty()) {
|
||||||
|
Text("No repositories added", modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(repositories) { url ->
|
||||||
|
ElevatedCard(onClick = {}) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Public, contentDescription = null)
|
||||||
|
Text(text = url, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
context.database.removeRepo(url)
|
||||||
|
coroutineScope.launch {
|
||||||
|
updateDispatcher.dispatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Remove")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -22,11 +22,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
|
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
|
||||||
import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
|
import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
|
||||||
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
|
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
|
||||||
@ -34,6 +30,7 @@ import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface
|
|||||||
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
|
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
|
||||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
|
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
|
||||||
import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher
|
import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher
|
||||||
|
import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
|
||||||
import me.rhunk.snapenhance.storage.isScriptEnabled
|
import me.rhunk.snapenhance.storage.isScriptEnabled
|
||||||
import me.rhunk.snapenhance.storage.setScriptEnabled
|
import me.rhunk.snapenhance.storage.setScriptEnabled
|
||||||
import me.rhunk.snapenhance.ui.manager.Routes
|
import me.rhunk.snapenhance.ui.manager.Routes
|
||||||
@ -101,6 +98,11 @@ class ScriptingRootSection : Routes.Route() {
|
|||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
context.androidContext.getUrlFromClipboard()?.let {
|
||||||
|
url = it
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Button(
|
Button(
|
||||||
enabled = url.isNotBlank(),
|
enabled = url.isNotBlank(),
|
||||||
|
@ -94,7 +94,7 @@ class EditThemeSection: Routes.Route() {
|
|||||||
text = {
|
text = {
|
||||||
var filter by remember { mutableStateOf("") }
|
var filter by remember { mutableStateOf("") }
|
||||||
val attributes = rememberAsyncMutableStateList(defaultValue = listOf(), keys = arrayOf(filter)) {
|
val attributes = rememberAsyncMutableStateList(defaultValue = listOf(), keys = arrayOf(filter)) {
|
||||||
AvailableThemingAttributes[ThemingAttribute.COLOR]?.filter { key ->
|
AvailableThemingAttributes[ThemingAttributeType.COLOR]?.filter { key ->
|
||||||
themeColors.none { it.key == key } && (key.contains(filter, ignoreCase = true) || attributesTranslation.getOrNull(key)?.contains(filter, ignoreCase = true) == true)
|
themeColors.none { it.key == key } && (key.contains(filter, ignoreCase = true) || attributesTranslation.getOrNull(key)?.contains(filter, ignoreCase = true) == true)
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
@ -173,6 +173,7 @@ class EditThemeSection: Routes.Route() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var themeName by remember { mutableStateOf("") }
|
var themeName by remember { mutableStateOf("") }
|
||||||
|
var themeDescription by remember { mutableStateOf("") }
|
||||||
var themeVersion by remember { mutableStateOf("1.0.0") }
|
var themeVersion by remember { mutableStateOf("1.0.0") }
|
||||||
var themeAuthor by remember { mutableStateOf("") }
|
var themeAuthor by remember { mutableStateOf("") }
|
||||||
var themeUpdateUrl by remember { mutableStateOf("") }
|
var themeUpdateUrl by remember { mutableStateOf("") }
|
||||||
@ -181,6 +182,7 @@ class EditThemeSection: Routes.Route() {
|
|||||||
currentThemeId?.let { themeId ->
|
currentThemeId?.let { themeId ->
|
||||||
context.database.getThemeInfo(themeId)?.also { theme ->
|
context.database.getThemeInfo(themeId)?.also { theme ->
|
||||||
themeName = theme.name
|
themeName = theme.name
|
||||||
|
themeDescription = theme.description ?: ""
|
||||||
theme.version?.let { themeVersion = it }
|
theme.version?.let { themeVersion = it }
|
||||||
themeAuthor = theme.author ?: ""
|
themeAuthor = theme.author ?: ""
|
||||||
themeUpdateUrl = theme.updateUrl ?: ""
|
themeUpdateUrl = theme.updateUrl ?: ""
|
||||||
@ -190,7 +192,7 @@ class EditThemeSection: Routes.Route() {
|
|||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
|
|
||||||
val themeContent by rememberAsyncMutableState(defaultValue = DatabaseThemeContent(), keys = arrayOf(themeInfo)) {
|
rememberAsyncMutableState(defaultValue = DatabaseThemeContent(), keys = arrayOf(themeInfo)) {
|
||||||
currentThemeId?.let {
|
currentThemeId?.let {
|
||||||
context.database.getThemeContent(it)?.also { content ->
|
context.database.getThemeContent(it)?.also { content ->
|
||||||
themeColors.clear()
|
themeColors.clear()
|
||||||
@ -209,6 +211,7 @@ class EditThemeSection: Routes.Route() {
|
|||||||
id = currentThemeId ?: -1,
|
id = currentThemeId ?: -1,
|
||||||
enabled = themeInfo?.enabled ?: false,
|
enabled = themeInfo?.enabled ?: false,
|
||||||
name = themeName,
|
name = themeName,
|
||||||
|
description = themeDescription,
|
||||||
version = themeVersion,
|
version = themeVersion,
|
||||||
author = themeAuthor,
|
author = themeAuthor,
|
||||||
updateUrl = themeUpdateUrl
|
updateUrl = themeUpdateUrl
|
||||||
@ -264,7 +267,7 @@ class EditThemeSection: Routes.Route() {
|
|||||||
onValueChange = { themeName = it },
|
onValueChange = { themeName = it },
|
||||||
label = { Text("Theme Name") },
|
label = { Text("Theme Name") },
|
||||||
colors = transparentTextFieldColors(),
|
colors = transparentTextFieldColors(),
|
||||||
maxLines = 1
|
singleLine = true,
|
||||||
)
|
)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (currentThemeId == null) {
|
if (currentThemeId == null) {
|
||||||
@ -285,7 +288,15 @@ class EditThemeSection: Routes.Route() {
|
|||||||
if (moreOptionsExpanded) {
|
if (moreOptionsExpanded) {
|
||||||
TextField(
|
TextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 1,
|
maxLines = 3,
|
||||||
|
value = themeDescription,
|
||||||
|
onValueChange = { themeDescription = it },
|
||||||
|
label = { Text("Description") },
|
||||||
|
colors = transparentTextFieldColors()
|
||||||
|
)
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
value = themeVersion,
|
value = themeVersion,
|
||||||
onValueChange = { themeVersion = it },
|
onValueChange = { themeVersion = it },
|
||||||
label = { Text("Version") },
|
label = { Text("Version") },
|
||||||
@ -293,7 +304,7 @@ class EditThemeSection: Routes.Route() {
|
|||||||
)
|
)
|
||||||
TextField(
|
TextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 1,
|
singleLine = true,
|
||||||
value = themeAuthor,
|
value = themeAuthor,
|
||||||
onValueChange = { themeAuthor = it },
|
onValueChange = { themeAuthor = it },
|
||||||
label = { Text("Author") },
|
label = { Text("Author") },
|
||||||
@ -301,7 +312,7 @@ class EditThemeSection: Routes.Route() {
|
|||||||
)
|
)
|
||||||
TextField(
|
TextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 1,
|
singleLine = true,
|
||||||
value = themeUpdateUrl,
|
value = themeUpdateUrl,
|
||||||
onValueChange = { themeUpdateUrl = it },
|
onValueChange = { themeUpdateUrl = it },
|
||||||
label = { Text("Update URL") },
|
label = { Text("Update URL") },
|
||||||
|
@ -0,0 +1,272 @@
|
|||||||
|
package me.rhunk.snapenhance.ui.manager.pages.theming
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import me.rhunk.snapenhance.common.data.RepositoryIndex
|
||||||
|
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
|
||||||
|
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
|
||||||
|
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
|
||||||
|
import me.rhunk.snapenhance.storage.getThemeList
|
||||||
|
import me.rhunk.snapenhance.storage.getRepositories
|
||||||
|
import me.rhunk.snapenhance.storage.getThemeIdByUpdateUrl
|
||||||
|
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
|
||||||
|
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
|
||||||
|
import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
|
|
||||||
|
private val cachedRepoIndexes = mutableStateMapOf<String, RepositoryIndex>()
|
||||||
|
private val cacheReloadDispatcher = AsyncUpdateDispatcher()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ThemeCatalog(root: ThemingRoot) {
|
||||||
|
val context = remember { root.context }
|
||||||
|
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||||
|
|
||||||
|
fun fetchRepoIndexes(): Map<String, RepositoryIndex>? {
|
||||||
|
val indexes = mutableMapOf<String, RepositoryIndex>()
|
||||||
|
|
||||||
|
context.database.getRepositories().forEach { rootUri ->
|
||||||
|
val indexUri = rootUri.toUri().buildUpon().appendPath("index.json").build()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
root.okHttpClient.newCall(
|
||||||
|
Request.Builder().url(indexUri.toString()).build()
|
||||||
|
).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
context.log.error("Failed to fetch theme index from $indexUri: ${response.code}")
|
||||||
|
context.shortToast("Failed to fetch index of $indexUri")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
indexes[rootUri] = context.gson.fromJson(response.body.charStream(), RepositoryIndex::class.java)
|
||||||
|
}.onFailure {
|
||||||
|
context.log.error("Failed to parse theme index from $indexUri", it)
|
||||||
|
context.shortToast("Failed to parse index of $indexUri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
context.log.error("Failed to fetch theme index from $indexUri", it)
|
||||||
|
context.shortToast("Failed to fetch index of $indexUri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexes
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun installTheme(themeUri: Uri) {
|
||||||
|
root.okHttpClient.newCall(
|
||||||
|
Request.Builder().url(themeUri.toString()).build()
|
||||||
|
).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
context.log.error("Failed to fetch theme from $themeUri: ${response.code}")
|
||||||
|
context.shortToast("Failed to fetch theme from $themeUri")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val themeContent = response.body.bytes().toString(Charsets.UTF_8)
|
||||||
|
root.importTheme(themeContent, themeUri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
suspend fun refreshCachedIndexes() {
|
||||||
|
isRefreshing = true
|
||||||
|
coroutineScope {
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
fetchRepoIndexes()?.let {
|
||||||
|
context.log.verbose("Fetched ${it.size} theme indexes")
|
||||||
|
it.forEach { (t, u) ->
|
||||||
|
context.log.verbose("Fetched theme index from $t with ${u.themes.size} themes")
|
||||||
|
}
|
||||||
|
synchronized(cachedRepoIndexes) {
|
||||||
|
cachedRepoIndexes.clear()
|
||||||
|
cachedRepoIndexes += it
|
||||||
|
}
|
||||||
|
cacheReloadDispatcher.dispatch()
|
||||||
|
delay(600)
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val installedThemes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = root.localReloadDispatcher, keys = arrayOf(cachedRepoIndexes)) {
|
||||||
|
context.database.getThemeList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val remoteThemes by rememberAsyncMutableState(defaultValue = listOf(), updateDispatcher = cacheReloadDispatcher, keys = arrayOf(root.searchFilter.value)) {
|
||||||
|
cachedRepoIndexes.entries.flatMap {
|
||||||
|
it.value.themes.map { theme -> it.key to theme }
|
||||||
|
}.let {
|
||||||
|
val filter = root.searchFilter.value
|
||||||
|
if (filter.isNotBlank()) {
|
||||||
|
it.filter { (_, theme) ->
|
||||||
|
theme.name.contains(filter, ignoreCase = true) || theme.description?.contains(filter, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
} else it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (cachedRepoIndexes.isNotEmpty()) return@LaunchedEffect
|
||||||
|
isRefreshing = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
refreshCachedIndexes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
refreshCachedIndexes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pullRefresh(pullRefreshState),
|
||||||
|
contentPadding = PaddingValues(8.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
if (remoteThemes.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No themes available",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Light
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(remoteThemes, key = { it.first + it.second.hashCode() }) { (_, themeManifest) ->
|
||||||
|
val themeUri = remember {
|
||||||
|
cachedRepoIndexes.entries.find { it.value.themes.contains(themeManifest) }?.key?.toUri()?.buildUpon()?.appendPath(themeManifest.filepath)?.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasUpdate by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(themeManifest)) {
|
||||||
|
installedThemes.takeIf { themeUri != null }?.find { it.updateUrl == themeUri.toString() }?.let { installedTheme ->
|
||||||
|
installedTheme.version != themeManifest.version
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isInstalling by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(themeManifest)) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isInstalled by rememberAsyncMutableState(defaultValue = true, keys = arrayOf(themeManifest)) {
|
||||||
|
context.database.getThemeIdByUpdateUrl(themeUri.toString()) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
ElevatedCard(onClick = {
|
||||||
|
//TODO: Show theme details
|
||||||
|
}) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Palette, contentDescription = null, modifier = Modifier.padding(16.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = themeManifest.name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
themeManifest.author?.let {
|
||||||
|
Text(
|
||||||
|
text = "by $it",
|
||||||
|
maxLines = 1,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
themeManifest.description?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hasUpdate) {
|
||||||
|
Text(
|
||||||
|
text = "Version ${themeManifest.version} available",
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (isInstalling) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
enabled = !isInstalled || hasUpdate,
|
||||||
|
onClick = {
|
||||||
|
isInstalling = true
|
||||||
|
context.coroutineScope.launch {
|
||||||
|
runCatching {
|
||||||
|
installTheme(themeUri ?: throw IllegalStateException("Failed to get theme URI"))
|
||||||
|
isInstalled = true
|
||||||
|
}.onFailure {
|
||||||
|
context.log.error("Failed to install theme ${themeManifest.name}", it)
|
||||||
|
context.shortToast("Failed to install theme ${themeManifest.name}. ${it.message}")
|
||||||
|
}
|
||||||
|
isInstalling = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (hasUpdate) {
|
||||||
|
Text("Update")
|
||||||
|
} else {
|
||||||
|
Text(if (isInstalled) "Installed" else "Install")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PullRefreshIndicator(
|
||||||
|
refreshing = isRefreshing,
|
||||||
|
state = pullRefreshState,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@ -23,7 +24,6 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.rhunk.snapenhance.common.data.DatabaseTheme
|
import me.rhunk.snapenhance.common.data.DatabaseTheme
|
||||||
@ -31,32 +31,33 @@ import me.rhunk.snapenhance.common.data.DatabaseThemeContent
|
|||||||
import me.rhunk.snapenhance.common.data.ExportedTheme
|
import me.rhunk.snapenhance.common.data.ExportedTheme
|
||||||
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
|
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
|
||||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
|
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
|
||||||
|
import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
|
||||||
|
import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
|
||||||
import me.rhunk.snapenhance.storage.*
|
import me.rhunk.snapenhance.storage.*
|
||||||
import me.rhunk.snapenhance.ui.manager.Routes
|
import me.rhunk.snapenhance.ui.manager.Routes
|
||||||
import me.rhunk.snapenhance.ui.util.*
|
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
||||||
|
import me.rhunk.snapenhance.ui.util.openFile
|
||||||
|
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
|
||||||
|
import me.rhunk.snapenhance.ui.util.saveFile
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
class ThemingRoot: Routes.Route() {
|
class ThemingRoot: Routes.Route() {
|
||||||
private val reloadDispatcher = AsyncUpdateDispatcher()
|
val localReloadDispatcher = AsyncUpdateDispatcher()
|
||||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||||
|
|
||||||
private val titles = listOf("Installed Themes", "Catalog")
|
|
||||||
private var currentPage by mutableIntStateOf(0)
|
private var currentPage by mutableIntStateOf(0)
|
||||||
private val okHttpClient by lazy { OkHttpClient() }
|
val okHttpClient by lazy { OkHttpClient() }
|
||||||
|
val searchFilter = mutableStateOf("")
|
||||||
|
|
||||||
|
|
||||||
private fun exportTheme(theme: DatabaseTheme) {
|
private fun exportTheme(theme: DatabaseTheme) {
|
||||||
context.coroutineScope.launch {
|
context.coroutineScope.launch {
|
||||||
val themeJson = ExportedTheme(
|
val exportedTheme = theme.toExportedTheme(context.database.getThemeContent(theme.id) ?: DatabaseThemeContent())
|
||||||
name = theme.name,
|
|
||||||
version = theme.version ?: "",
|
|
||||||
author = theme.author ?: "",
|
|
||||||
content = context.database.getThemeContent(theme.id) ?: DatabaseThemeContent()
|
|
||||||
)
|
|
||||||
|
|
||||||
activityLauncherHelper.saveFile(theme.name.replace(" ", "_").lowercase() + ".json") { uri ->
|
activityLauncherHelper.saveFile(theme.name.replace(" ", "_").lowercase() + ".json") { uri ->
|
||||||
runCatching {
|
runCatching {
|
||||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
|
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
|
||||||
outputStream.write(context.gson.toJson(themeJson).toByteArray())
|
outputStream.write(context.gson.toJson(exportedTheme).toByteArray())
|
||||||
outputStream.flush()
|
outputStream.flush()
|
||||||
}
|
}
|
||||||
context.shortToast("Theme exported successfully")
|
context.shortToast("Theme exported successfully")
|
||||||
@ -76,27 +77,32 @@ class ThemingRoot: Routes.Route() {
|
|||||||
context.database.setThemeContent(themeId, context.database.getThemeContent(theme.id) ?: DatabaseThemeContent())
|
context.database.setThemeContent(themeId, context.database.getThemeContent(theme.id) ?: DatabaseThemeContent())
|
||||||
context.shortToast("Theme duplicated successfully")
|
context.shortToast("Theme duplicated successfully")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
reloadDispatcher.dispatch()
|
localReloadDispatcher.dispatch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun importTheme(content: String, url: String? = null) {
|
suspend fun importTheme(content: String, updateUrl: String? = null) {
|
||||||
val theme = context.gson.fromJson(content, ExportedTheme::class.java)
|
val theme = context.gson.fromJson(content, ExportedTheme::class.java)
|
||||||
val themeId = context.database.addOrUpdateTheme(
|
val existingTheme = updateUrl?.let {
|
||||||
DatabaseTheme(
|
context.database.getThemeIdByUpdateUrl(it)
|
||||||
id = -1,
|
}?.let {
|
||||||
enabled = false,
|
context.database.getThemeInfo(it)
|
||||||
name = theme.name,
|
}
|
||||||
version = theme.version,
|
val databaseTheme = theme.toDatabaseTheme(
|
||||||
author = theme.author,
|
updateUrl = updateUrl,
|
||||||
updateUrl = url
|
enabled = existingTheme?.enabled ?: false
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val themeId = context.database.addOrUpdateTheme(
|
||||||
|
themeId = existingTheme?.id,
|
||||||
|
theme = databaseTheme
|
||||||
|
)
|
||||||
|
|
||||||
context.database.setThemeContent(themeId, theme.content)
|
context.database.setThemeContent(themeId, theme.content)
|
||||||
context.shortToast("Theme imported successfully")
|
context.shortToast("Theme imported successfully")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
reloadDispatcher.dispatch()
|
localReloadDispatcher.dispatch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,6 +141,40 @@ class ThemingRoot: Routes.Route() {
|
|||||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val topBarActions: @Composable (RowScope.() -> Unit) = {
|
||||||
|
var showSearchBar by remember { mutableStateOf(false) }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (showSearchBar) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchFilter.value,
|
||||||
|
onValueChange = { searchFilter.value = it },
|
||||||
|
placeholder = { Text("Search") },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onGloballyPositioned {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
colors = transparentTextFieldColors()
|
||||||
|
)
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
searchFilter.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = {
|
||||||
|
showSearchBar = !showSearchBar
|
||||||
|
}) {
|
||||||
|
Icon(if (showSearchBar) Icons.Default.Close else Icons.Default.Search, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override val floatingActionButton: @Composable () -> Unit = {
|
override val floatingActionButton: @Composable () -> Unit = {
|
||||||
var showImportFromUrlDialog by remember { mutableStateOf(false) }
|
var showImportFromUrlDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -154,10 +194,14 @@ class ThemingRoot: Routes.Route() {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
|
.onGloballyPositioned {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(100)
|
context.androidContext.getUrlFromClipboard()?.let {
|
||||||
focusRequester.requestFocus()
|
url = it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
@ -226,14 +270,36 @@ class ThemingRoot: Routes.Route() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
1 -> {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
routes.manageRepos.navigate()
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Default.Public, contentDescription = null)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text("Manage repositories")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InstalledThemes() {
|
private fun InstalledThemes() {
|
||||||
val themes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
|
val themes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = localReloadDispatcher, keys = arrayOf(searchFilter.value)) {
|
||||||
context.database.getThemeList()
|
context.database.getThemeList().let {
|
||||||
|
val filter = searchFilter.value
|
||||||
|
if (filter.isNotBlank()) {
|
||||||
|
it.filter { theme ->
|
||||||
|
theme.name.contains(filter, ignoreCase = true) ||
|
||||||
|
theme.author?.contains(filter, ignoreCase = true) == true ||
|
||||||
|
theme.description?.contains(filter, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
} else it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@ -322,10 +388,12 @@ class ThemingRoot: Routes.Route() {
|
|||||||
) {
|
) {
|
||||||
actionsRow.forEach { entry ->
|
actionsRow.forEach { entry ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().clickable {
|
modifier = Modifier
|
||||||
showSettings = false
|
.fillMaxWidth()
|
||||||
entry.value()
|
.clickable {
|
||||||
},
|
showSettings = false
|
||||||
|
entry.value()
|
||||||
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(entry.key.second, contentDescription = null, modifier = Modifier.padding(16.dp))
|
Icon(entry.key.second, contentDescription = null, modifier = Modifier.padding(16.dp))
|
||||||
@ -345,18 +413,11 @@ class ThemingRoot: Routes.Route() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ThemeCatalog() {
|
|
||||||
val installedThemes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
|
|
||||||
context.database.getThemeList()
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(text = "Not Implemented", modifier = Modifier.fillMaxWidth().padding(5.dp), textAlign = TextAlign.Center)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val titles = remember { listOf("Installed Themes", "Catalog") }
|
||||||
val pagerState = rememberPagerState { titles.size }
|
val pagerState = rememberPagerState { titles.size }
|
||||||
currentPage = pagerState.currentPage
|
currentPage = pagerState.currentPage
|
||||||
|
|
||||||
@ -394,7 +455,7 @@ class ThemingRoot: Routes.Route() {
|
|||||||
) { page ->
|
) { page ->
|
||||||
when (page) {
|
when (page) {
|
||||||
0 -> InstalledThemes()
|
0 -> InstalledThemes()
|
||||||
1 -> ThemeCatalog()
|
1 -> ThemeCatalog(this@ThemingRoot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -74,9 +74,12 @@ fun LogsTab(
|
|||||||
|
|
||||||
suspend fun loadNewLogs() {
|
suspend fun loadNewLogs() {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
logs.addAll(getPaginatedLogs(pageIndex).apply {
|
getPaginatedLogs(pageIndex).let {
|
||||||
pageIndex += 1
|
withContext(Dispatchers.Main) {
|
||||||
})
|
logs.addAll(it)
|
||||||
|
pageIndex += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,6 +38,7 @@
|
|||||||
"file_imports": "File Imports",
|
"file_imports": "File Imports",
|
||||||
"theming": "Theming",
|
"theming": "Theming",
|
||||||
"edit_theme": "Edit Theme",
|
"edit_theme": "Edit Theme",
|
||||||
|
"manage_repos": "Manage Repositories",
|
||||||
"social": "Social",
|
"social": "Social",
|
||||||
"manage_scope": "Manage Scope",
|
"manage_scope": "Manage Scope",
|
||||||
"messaging_preview": "Preview",
|
"messaging_preview": "Preview",
|
||||||
|
@ -39,7 +39,7 @@ class ConversationInfo(
|
|||||||
val usernames: List<String>
|
val usernames: List<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
class TrackerLog(
|
data class TrackerLog(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val conversationId: String,
|
val conversationId: String,
|
||||||
|
@ -23,24 +23,60 @@ data class DatabaseTheme(
|
|||||||
val id: Int,
|
val id: Int,
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val description: String?,
|
||||||
val version: String?,
|
val version: String?,
|
||||||
val author: String?,
|
val author: String?,
|
||||||
val updateUrl: String?,
|
val updateUrl: String?,
|
||||||
)
|
) {
|
||||||
|
fun toExportedTheme(content: DatabaseThemeContent): ExportedTheme {
|
||||||
|
return ExportedTheme(
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
version = version,
|
||||||
|
author = author,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class ExportedTheme(
|
data class ExportedTheme(
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val description: String?,
|
||||||
val version: String?,
|
val version: String?,
|
||||||
val author: String?,
|
val author: String?,
|
||||||
val content: DatabaseThemeContent,
|
val content: DatabaseThemeContent,
|
||||||
|
) {
|
||||||
|
fun toDatabaseTheme(id: Int = -1, updateUrl: String? = null, enabled: Boolean = false): DatabaseTheme {
|
||||||
|
return DatabaseTheme(
|
||||||
|
id = id,
|
||||||
|
enabled = enabled,
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
version = version,
|
||||||
|
author = author,
|
||||||
|
updateUrl = updateUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RepositoryThemeManifest(
|
||||||
|
val name: String,
|
||||||
|
val author: String?,
|
||||||
|
val description: String?,
|
||||||
|
val version: String?,
|
||||||
|
val filepath: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ThemingAttribute {
|
data class RepositoryIndex(
|
||||||
|
val themes: List<RepositoryThemeManifest> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ThemingAttributeType {
|
||||||
COLOR
|
COLOR
|
||||||
}
|
}
|
||||||
|
|
||||||
val AvailableThemingAttributes = mapOf(
|
val AvailableThemingAttributes = mapOf(
|
||||||
ThemingAttribute.COLOR to listOf(
|
ThemingAttributeType.COLOR to listOf(
|
||||||
"sigColorTextPrimary",
|
"sigColorTextPrimary",
|
||||||
"sigColorBackgroundSurface",
|
"sigColorBackgroundSurface",
|
||||||
"sigColorBackgroundMain",
|
"sigColorBackgroundMain",
|
||||||
|
@ -20,8 +20,23 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Context.copyToClipboard(data: String, label: String = "Copied Text") {
|
fun Context.copyToClipboard(data: String, label: String = "Copied Text") {
|
||||||
getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(
|
runCatching {
|
||||||
ClipData.newPlainText(label, data))
|
getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(
|
||||||
|
ClipData.newPlainText(label, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Context.getTextFromClipboard(): String? {
|
||||||
|
return runCatching {
|
||||||
|
getSystemService(android.content.ClipboardManager::class.java).primaryClip
|
||||||
|
?.takeIf { it.itemCount > 0 }
|
||||||
|
?.getItemAt(0)
|
||||||
|
?.text?.toString()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Context.getUrlFromClipboard(): String? {
|
||||||
|
return getTextFromClipboard()?.takeIf { it.startsWith("http") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFileDescriptor {
|
fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFileDescriptor {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user