diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt index 069704e6..a5bbf2ff 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt @@ -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", + ), )) } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt new file mode 100644 index 00000000..1cd9843d --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt @@ -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 { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT url FROM repositories", null).use { cursor -> + val repos = mutableListOf() + 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) + }) + } +} + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Theming.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Theming.kt index 9668029a..d3ef48da 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Theming.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Theming.kt @@ -19,6 +19,7 @@ fun AppDatabase.getThemeList(): List { 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) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt index 62077a73..6d26a75d 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt @@ -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) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt new file mode 100644 index 00000000..fa75f9d4 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt @@ -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") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt index 2af2ff13..d67490c4 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt @@ -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(), diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/EditThemeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/EditThemeSection.kt index ee6929d9..c5d4f029 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/EditThemeSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/EditThemeSection.kt @@ -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") }, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemeCatalog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemeCatalog.kt new file mode 100644 index 00000000..98f817db --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemeCatalog.kt @@ -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() +private val cacheReloadDispatcher = AsyncUpdateDispatcher() + +@Composable +fun ThemeCatalog(root: ThemingRoot) { + val context = remember { root.context } + val coroutineScope = rememberCoroutineScope { Dispatchers.IO } + + fun fetchRepoIndexes(): Map? { + val indexes = mutableMapOf() + + 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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemingRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemingRoot.kt index 4b76de31..04d693ee 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemingRoot.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemingRoot.kt @@ -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) } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt index 860e3f9e..cacd122f 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt @@ -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 + } + } } } diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 5945c3aa..5bf26156 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -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", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt index 2deec298..5b472f8c 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt @@ -39,7 +39,7 @@ class ConversationInfo( val usernames: List ) -class TrackerLog( +data class TrackerLog( val id: Int, val timestamp: Long, val conversationId: String, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/ThemingObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/ThemingObjects.kt index 2b6294bc..c95d4a61 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/ThemingObjects.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/ThemingObjects.kt @@ -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 = emptyList(), +) + +enum class ThemingAttributeType { COLOR } val AvailableThemingAttributes = mapOf( - ThemingAttribute.COLOR to listOf( + ThemingAttributeType.COLOR to listOf( "sigColorTextPrimary", "sigColorBackgroundSurface", "sigColorBackgroundMain", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt index c3487b6b..0651d582 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt @@ -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 {