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",
"enabled BOOLEAN DEFAULT 0",
"name VARCHAR",
"description TEXT",
"version VARCHAR",
"author VARCHAR",
"updateUrl VARCHAR",
"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,
enabled = cursor.getIntOrNull("enabled") == 1,
name = cursor.getStringOrNull("name") ?: continue,
description = cursor.getStringOrNull("description"),
version = cursor.getStringOrNull("version"),
author = cursor.getStringOrNull("author"),
updateUrl = cursor.getStringOrNull("updateUrl")
@ -38,6 +39,7 @@ fun AppDatabase.getThemeInfo(id: Int): DatabaseTheme? {
id = cursor.getIntOrNull("id") ?: return@use null,
enabled = cursor.getIntOrNull("enabled") == 1,
name = cursor.getStringOrNull("name") ?: return@use null,
description = cursor.getStringOrNull("description"),
version = cursor.getStringOrNull("version"),
author = cursor.getStringOrNull("author"),
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 {
return runBlocking(executor.asCoroutineDispatcher()) {
val contentValues = ContentValues().apply {
put("enabled", if (theme.enabled) 1 else 0)
put("name", theme.name)
put("description", theme.description)
put("version", theme.version)
put("author", theme.author)
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.SocialRootSection
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.tracker.EditRule
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 theming = route(RouteInfo("theming"), ThemingRoot()).parent(home)
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 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.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
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.rememberAsyncMutableState
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.setScriptEnabled
import me.rhunk.snapenhance.ui.manager.Routes
@ -101,6 +98,11 @@ class ScriptingRootSection : Routes.Route() {
focusRequester.requestFocus()
}
)
LaunchedEffect(Unit) {
context.androidContext.getUrlFromClipboard()?.let {
url = it
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
enabled = url.isNotBlank(),

View File

@ -94,7 +94,7 @@ class EditThemeSection: Routes.Route() {
text = {
var filter by remember { mutableStateOf("") }
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)
} ?: emptyList()
}
@ -173,6 +173,7 @@ class EditThemeSection: Routes.Route() {
}
var themeName by remember { mutableStateOf("") }
var themeDescription by remember { mutableStateOf("") }
var themeVersion by remember { mutableStateOf("1.0.0") }
var themeAuthor by remember { mutableStateOf("") }
var themeUpdateUrl by remember { mutableStateOf("") }
@ -181,6 +182,7 @@ class EditThemeSection: Routes.Route() {
currentThemeId?.let { themeId ->
context.database.getThemeInfo(themeId)?.also { theme ->
themeName = theme.name
themeDescription = theme.description ?: ""
theme.version?.let { themeVersion = it }
themeAuthor = theme.author ?: ""
themeUpdateUrl = theme.updateUrl ?: ""
@ -190,7 +192,7 @@ class EditThemeSection: Routes.Route() {
val lazyListState = rememberLazyListState()
val themeContent by rememberAsyncMutableState(defaultValue = DatabaseThemeContent(), keys = arrayOf(themeInfo)) {
rememberAsyncMutableState(defaultValue = DatabaseThemeContent(), keys = arrayOf(themeInfo)) {
currentThemeId?.let {
context.database.getThemeContent(it)?.also { content ->
themeColors.clear()
@ -209,6 +211,7 @@ class EditThemeSection: Routes.Route() {
id = currentThemeId ?: -1,
enabled = themeInfo?.enabled ?: false,
name = themeName,
description = themeDescription,
version = themeVersion,
author = themeAuthor,
updateUrl = themeUpdateUrl
@ -264,7 +267,7 @@ class EditThemeSection: Routes.Route() {
onValueChange = { themeName = it },
label = { Text("Theme Name") },
colors = transparentTextFieldColors(),
maxLines = 1
singleLine = true,
)
LaunchedEffect(Unit) {
if (currentThemeId == null) {
@ -285,7 +288,15 @@ class EditThemeSection: Routes.Route() {
if (moreOptionsExpanded) {
TextField(
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,
onValueChange = { themeVersion = it },
label = { Text("Version") },
@ -293,7 +304,7 @@ class EditThemeSection: Routes.Route() {
)
TextField(
modifier = Modifier.fillMaxWidth(),
maxLines = 1,
singleLine = true,
value = themeAuthor,
onValueChange = { themeAuthor = it },
label = { Text("Author") },
@ -301,7 +312,7 @@ class EditThemeSection: Routes.Route() {
)
TextField(
modifier = Modifier.fillMaxWidth(),
maxLines = 1,
singleLine = true,
value = themeUpdateUrl,
onValueChange = { themeUpdateUrl = it },
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.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
@ -23,7 +24,6 @@ import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.ui.AsyncUpdateDispatcher
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.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
class ThemingRoot: Routes.Route() {
private val reloadDispatcher = AsyncUpdateDispatcher()
val localReloadDispatcher = AsyncUpdateDispatcher()
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val titles = listOf("Installed Themes", "Catalog")
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) {
context.coroutineScope.launch {
val themeJson = ExportedTheme(
name = theme.name,
version = theme.version ?: "",
author = theme.author ?: "",
content = context.database.getThemeContent(theme.id) ?: DatabaseThemeContent()
)
val exportedTheme = theme.toExportedTheme(context.database.getThemeContent(theme.id) ?: DatabaseThemeContent())
activityLauncherHelper.saveFile(theme.name.replace(" ", "_").lowercase() + ".json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
outputStream.write(context.gson.toJson(themeJson).toByteArray())
outputStream.write(context.gson.toJson(exportedTheme).toByteArray())
outputStream.flush()
}
context.shortToast("Theme exported successfully")
@ -76,27 +77,32 @@ class ThemingRoot: Routes.Route() {
context.database.setThemeContent(themeId, context.database.getThemeContent(theme.id) ?: DatabaseThemeContent())
context.shortToast("Theme duplicated successfully")
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 themeId = context.database.addOrUpdateTheme(
DatabaseTheme(
id = -1,
enabled = false,
name = theme.name,
version = theme.version,
author = theme.author,
updateUrl = url
)
val existingTheme = updateUrl?.let {
context.database.getThemeIdByUpdateUrl(it)
}?.let {
context.database.getThemeInfo(it)
}
val databaseTheme = theme.toDatabaseTheme(
updateUrl = updateUrl,
enabled = existingTheme?.enabled ?: false
)
val themeId = context.database.addOrUpdateTheme(
themeId = existingTheme?.id,
theme = databaseTheme
)
context.database.setThemeContent(themeId, theme.content)
context.shortToast("Theme imported successfully")
withContext(Dispatchers.Main) {
reloadDispatcher.dispatch()
localReloadDispatcher.dispatch()
}
}
@ -135,6 +141,40 @@ class ThemingRoot: Routes.Route() {
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 = {
var showImportFromUrlDialog by remember { mutableStateOf(false) }
@ -154,10 +194,14 @@ class ThemingRoot: Routes.Route() {
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onGloballyPositioned {
focusRequester.requestFocus()
}
)
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
context.androidContext.getUrlFromClipboard()?.let {
url = it
}
}
},
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
private fun InstalledThemes() {
val themes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
context.database.getThemeList()
val themes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = localReloadDispatcher, keys = arrayOf(searchFilter.value)) {
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(
@ -322,10 +388,12 @@ class ThemingRoot: Routes.Route() {
) {
actionsRow.forEach { entry ->
Row(
modifier = Modifier.fillMaxWidth().clickable {
showSettings = false
entry.value()
},
modifier = Modifier
.fillMaxWidth()
.clickable {
showSettings = false
entry.value()
},
verticalAlignment = Alignment.CenterVertically
) {
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)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val titles = remember { listOf("Installed Themes", "Catalog") }
val pagerState = rememberPagerState { titles.size }
currentPage = pagerState.currentPage
@ -394,7 +455,7 @@ class ThemingRoot: Routes.Route() {
) { page ->
when (page) {
0 -> InstalledThemes()
1 -> ThemeCatalog()
1 -> ThemeCatalog(this@ThemingRoot)
}
}
}

View File

@ -74,9 +74,12 @@ fun LogsTab(
suspend fun loadNewLogs() {
withContext(Dispatchers.IO) {
logs.addAll(getPaginatedLogs(pageIndex).apply {
pageIndex += 1
})
getPaginatedLogs(pageIndex).let {
withContext(Dispatchers.Main) {
logs.addAll(it)
pageIndex += 1
}
}
}
}

View File

@ -38,6 +38,7 @@
"file_imports": "File Imports",
"theming": "Theming",
"edit_theme": "Edit Theme",
"manage_repos": "Manage Repositories",
"social": "Social",
"manage_scope": "Manage Scope",
"messaging_preview": "Preview",

View File

@ -39,7 +39,7 @@ class ConversationInfo(
val usernames: List<String>
)
class TrackerLog(
data class TrackerLog(
val id: Int,
val timestamp: Long,
val conversationId: String,

View File

@ -23,24 +23,60 @@ data class DatabaseTheme(
val id: Int,
val enabled: Boolean,
val name: String,
val description: String?,
val version: String?,
val author: String?,
val updateUrl: String?,
)
) {
fun toExportedTheme(content: DatabaseThemeContent): ExportedTheme {
return ExportedTheme(
name = name,
description = description,
version = version,
author = author,
content = content,
)
}
}
data class ExportedTheme(
val name: String,
val description: String?,
val version: String?,
val author: String?,
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
}
val AvailableThemingAttributes = mapOf(
ThemingAttribute.COLOR to listOf(
ThemingAttributeType.COLOR to listOf(
"sigColorTextPrimary",
"sigColorBackgroundSurface",
"sigColorBackgroundMain",

View File

@ -20,8 +20,23 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) =
}
fun Context.copyToClipboard(data: String, label: String = "Copied Text") {
getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(
ClipData.newPlainText(label, data))
runCatching {
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 {