feat: catalog

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
rhunk 2024-07-04 16:18:39 +02:00
parent 2cb3db042f
commit f9974b0e84
14 changed files with 701 additions and 61 deletions

View File

@ -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",
),
)) ))
} }
} }

View File

@ -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)
})
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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")
}
}
}
}
}
}
}

View File

@ -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(),

View File

@ -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") },

View File

@ -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)
)
}
}

View File

@ -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)
} }
} }
} }

View File

@ -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
}
}
} }
} }

View File

@ -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",

View File

@ -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,

View File

@ -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",

View File

@ -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 {