commit 25dd79af68247f7cd0a05f63ca0163a1923da926
parent d1283b0ef764c6e044410b20fc68c0e7d4a8717b
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date: Mon, 3 Jun 2024 01:30:12 +0200
refactor: root sections
Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
Diffstat:
11 files changed, 2513 insertions(+), 2517 deletions(-)
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
@@ -2,11 +2,7 @@ package me.rhunk.snapenhance.ui.manager
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.DataObject
-import androidx.compose.material.icons.filled.Group
-import androidx.compose.material.icons.filled.Home
-import androidx.compose.material.icons.filled.Stars
-import androidx.compose.material.icons.filled.TaskAlt
+import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavBackStackEntry
@@ -18,16 +14,16 @@ import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.ui.manager.pages.BetterLocationRoot
import me.rhunk.snapenhance.ui.manager.pages.FileImportsRoot
import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot
-import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
-import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot
+import me.rhunk.snapenhance.ui.manager.pages.TasksRootSection
+import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRootSection
import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs
-import me.rhunk.snapenhance.ui.manager.pages.home.HomeRoot
+import me.rhunk.snapenhance.ui.manager.pages.home.HomeRootSection
import me.rhunk.snapenhance.ui.manager.pages.home.HomeSettings
-import me.rhunk.snapenhance.ui.manager.pages.scripting.ScriptingRoot
+import me.rhunk.snapenhance.ui.manager.pages.scripting.ScriptingRootSection
import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories
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.SocialRoot
+import me.rhunk.snapenhance.ui.manager.pages.social.SocialRootSection
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
@@ -50,11 +46,11 @@ class Routes(
lateinit var navController: NavController
private val routes = mutableListOf<Route>()
- val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true), TasksRoot())
+ val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true), TasksRootSection())
- val features = route(RouteInfo("features", icon = Icons.Default.Stars, primary = true), FeaturesRoot())
+ val features = route(RouteInfo("features", icon = Icons.Default.Stars, primary = true), FeaturesRootSection())
- val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRoot())
+ val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRootSection())
val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home)
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home)
@@ -62,12 +58,12 @@ class Routes(
val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule())
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
- val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot())
+ 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 messagingPreview = route(RouteInfo("messaging_preview/?scope={scope}&id={id}"), MessagingPreview()).parent(social)
val loggedStories = route(RouteInfo("logged_stories/?id={id}"), LoggedStories()).parent(social)
- val scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true), ScriptingRoot())
+ val scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true), ScriptingRootSection())
val betterLocation = route(RouteInfo("better_location", showInNavBar = false, primary = true), BetterLocationRoot())
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt
@@ -1,474 +0,0 @@
-package me.rhunk.snapenhance.ui.manager.pages
-
- import android.content.Intent
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.OpenInNew
-import androidx.compose.material.icons.filled.*
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.StrokeCap
-import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
-import androidx.documentfile.provider.DocumentFile
-import androidx.lifecycle.Lifecycle
-import androidx.navigation.NavBackStackEntry
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import me.rhunk.snapenhance.bridge.DownloadCallback
-import me.rhunk.snapenhance.common.data.download.DownloadMetadata
-import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
-import me.rhunk.snapenhance.common.data.download.createNewFilePath
-import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
-import me.rhunk.snapenhance.common.util.ktx.longHashCode
-import me.rhunk.snapenhance.download.DownloadProcessor
-import me.rhunk.snapenhance.download.FFMpegProcessor
-import me.rhunk.snapenhance.task.PendingTask
-import me.rhunk.snapenhance.task.PendingTaskListener
-import me.rhunk.snapenhance.task.Task
-import me.rhunk.snapenhance.task.TaskStatus
-import me.rhunk.snapenhance.task.TaskType
-import me.rhunk.snapenhance.ui.manager.Routes
-import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
-import java.io.File
-import java.util.UUID
-import kotlin.math.absoluteValue
-
-class TasksRoot : Routes.Route() {
- private var activeTasks by mutableStateOf(listOf<PendingTask>())
- private lateinit var recentTasks: MutableList<Task>
- private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
-
- private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) {
- scope.launch(Dispatchers.IO) {
- activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList()
- }
- }
-
- private fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) {
- val firstTask = selection.first().first
-
- val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16)
- val pendingTask = context.taskManager.createPendingTask(
- Task(TaskType.DOWNLOAD, "Merge ${selection.size} files", firstTask.author, taskHash)
- )
- pendingTask.status = TaskStatus.RUNNING
- fetchActiveTasks()
-
- context.coroutineScope.launch {
- val filesToMerge = mutableListOf<File>()
-
- selection.forEach { (task, documentFile) ->
- val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also {
- it.deleteOnExit()
- }
-
- runCatching {
- pendingTask.updateProgress("Copying ${documentFile.name}")
- context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
- //copy with progress
- val length = documentFile.length().toFloat()
- tempFile.outputStream().use { outputStream ->
- val buffer = ByteArray(16 * 1024)
- var read: Int
- while (inputStream.read(buffer).also { read = it } != -1) {
- outputStream.write(buffer, 0, read)
- pendingTask.updateProgress("Copying ${documentFile.name}", (outputStream.channel.position().toFloat() / length * 100f).toInt())
- }
- outputStream.flush()
- filesToMerge.add(tempFile)
- }
- }
- }.onFailure {
- pendingTask.fail("Failed to copy file $documentFile to $tempFile")
- filesToMerge.forEach { it.delete() }
- return@launch
- }
- }
-
- val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also {
- it.deleteOnExit()
- }
-
- runCatching {
- context.shortToast(translation.format("merge_files_toast", "count" to filesToMerge.size.toString()))
- FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
- FFMpegProcessor.Request(FFMpegProcessor.Action.MERGE_MEDIA, filesToMerge.map { it.absolutePath }, mergedFile)
- )
- DownloadProcessor(context, object: DownloadCallback.Default() {
- override fun onSuccess(outputPath: String) {
- context.log.verbose("Merged files to $outputPath")
- }
- }).saveMediaToGallery(pendingTask, mergedFile, DownloadMetadata(
- mediaIdentifier = taskHash,
- outputPath = createNewFilePath(
- context.config.root,
- taskHash,
- downloadSource = MediaDownloadSource.MERGED,
- mediaAuthor = firstTask.author,
- creationTimestamp = System.currentTimeMillis()
- ),
- mediaAuthor = firstTask.author,
- downloadSource = MediaDownloadSource.MERGED.translate(context.translation),
- iconUrl = null
- ))
- }.onFailure {
- context.log.error("Failed to merge files", it)
- pendingTask.fail(it.message ?: "Failed to merge files")
- }.onSuccess {
- pendingTask.success()
- }
- filesToMerge.forEach { it.delete() }
- mergedFile.delete()
- }.also {
- pendingTask.addListener(PendingTaskListener(onCancel = { it.cancel() }))
- }
- }
-
- override val topBarActions: @Composable (RowScope.() -> Unit) = {
- var showConfirmDialog by remember { mutableStateOf(false) }
- val coroutineScope = rememberCoroutineScope()
-
- if (taskSelection.size == 1) {
- val selectionExists by rememberAsyncMutableState(defaultValue = false) {
- taskSelection.firstOrNull()?.second?.exists() == true
- }
- if (selectionExists) {
- taskSelection.firstOrNull()?.second?.let { documentFile ->
- IconButton(onClick = {
- runCatching {
- context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
- setDataAndType(documentFile.uri, documentFile.type)
- flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
- })
- taskSelection.clear()
- }.onFailure {
- context.log.error("Failed to open file ${taskSelection.first().second}", it)
- }
- }) {
- Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Open")
- }
- }
- }
- }
-
- if (taskSelection.size > 1) {
- val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
- taskSelection.all { it.second?.type?.contains("video") == true }
- }
-
- if (canMergeSelection) {
- IconButton(onClick = {
- mergeSelection(taskSelection.toList().also {
- taskSelection.clear()
- }.map { it.first to it.second!! })
- }) {
- Icon(Icons.Filled.Merge, contentDescription = "Merge")
- }
- }
- }
-
- IconButton(onClick = {
- showConfirmDialog = true
- }) {
- Icon(Icons.Filled.Delete, contentDescription = "Clear tasks")
- }
-
- if (showConfirmDialog) {
- var alsoDeleteFiles by remember { mutableStateOf(false) }
-
- AlertDialog(
- onDismissRequest = { showConfirmDialog = false },
- title = {
- if (taskSelection.isNotEmpty()) {
- Text(translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString()))
- } else {
- Text(translation["remove_all_tasks_confirm"])
- }
- },
- text = {
- Column {
- if (taskSelection.isNotEmpty()) {
- Text(translation["remove_selected_tasks_title"])
- Row (
- modifier = Modifier
- .padding(top = 10.dp)
- .fillMaxWidth()
- .clickable {
- alsoDeleteFiles = !alsoDeleteFiles
- },
- horizontalArrangement = Arrangement.spacedBy(5.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Checkbox(checked = alsoDeleteFiles, onCheckedChange = {
- alsoDeleteFiles = it
- })
- Text(translation["delete_files_option"])
- }
- } else {
- Text(translation["remove_all_tasks_title"])
- }
- }
- },
- confirmButton = {
- Button(
- onClick = {
- showConfirmDialog = false
- if (taskSelection.isNotEmpty()) {
- taskSelection.forEach { (task, documentFile) ->
- coroutineScope.launch(Dispatchers.IO) {
- context.taskManager.removeTask(task)
- if (alsoDeleteFiles) {
- documentFile?.delete()
- }
- }
- recentTasks.remove(task)
- }
- activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) }
- taskSelection.clear()
- } else {
- coroutineScope.launch(Dispatchers.IO) {
- context.taskManager.clearAllTasks()
- }
- recentTasks.clear()
- activeTasks.forEach {
- runCatching {
- it.cancel()
- }.onFailure { throwable ->
- context.log.error("Failed to cancel task $it", throwable)
- }
- }
- activeTasks = listOf()
- context.taskManager.getActiveTasks().clear()
- }
- }
- ) {
- Text(context.translation["button.positive"])
- }
- },
- dismissButton = {
- Button(
- onClick = {
- showConfirmDialog = false
- }
- ) {
- Text(context.translation["button.negative"])
- }
- }
- )
- }
- }
-
- @Composable
- private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
- var taskStatus by remember { mutableStateOf(task.status) }
- var taskProgressLabel by remember { mutableStateOf<String?>(null) }
- var taskProgress by remember { mutableIntStateOf(-1) }
- val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
-
- var documentFileMimeType by remember { mutableStateOf("") }
- var isDocumentFileReadable by remember { mutableStateOf(true) }
- val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) {
- DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
- documentFileMimeType = type ?: ""
- isDocumentFileReadable = canRead()
- }
- }
-
-
- val listener = remember { PendingTaskListener(
- onStateChange = {
- taskStatus = it
- },
- onProgress = { label, progress ->
- taskProgressLabel = label
- taskProgress = progress
- }
- ) }
-
- LaunchedEffect(Unit) {
- pendingTask?.addListener(listener)
- }
-
- DisposableEffect(Unit) {
- onDispose {
- pendingTask?.removeListener(listener)
- }
- }
-
- OutlinedCard(modifier = modifier
- .clickable {
- if (isSelected) {
- taskSelection.removeIf { it.first == task }
- return@clickable
- }
- taskSelection.add(task to documentFile)
- }
- .let {
- if (isSelected) {
- it
- .border(2.dp, MaterialTheme.colorScheme.primary)
- .clip(MaterialTheme.shapes.medium)
- } else it
- }) {
- Row(
- modifier = Modifier.padding(15.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier.padding(end = 15.dp)
- ) {
- documentFile?.let {
- when {
- !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
- documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
- documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
- documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
- else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
- }
- } ?: run {
- when (task.type) {
- TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download")
- TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action")
- }
- }
- }
- Column(
- modifier = Modifier.weight(1f),
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(task.title, style = MaterialTheme.typography.bodyMedium)
- task.author?.takeIf { it != "null" }?.let {
- Spacer(modifier = Modifier.width(5.dp))
- Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- }
- Text(task.hash, style = MaterialTheme.typography.labelSmall)
- Column(
- modifier = Modifier.padding(top = 5.dp),
- verticalArrangement = Arrangement.spacedBy(5.dp)
- ) {
- if (taskStatus.isFinalStage()) {
- if (taskStatus != TaskStatus.SUCCESS) {
- Text("$taskStatus", style = MaterialTheme.typography.bodySmall)
- }
- } else {
- taskProgressLabel?.let {
- Text(it, style = MaterialTheme.typography.bodySmall)
- }
- if (taskProgress != -1) {
- LinearProgressIndicator(
- progress = { taskProgress.toFloat() / 100f },
- strokeCap = StrokeCap.Round,
- )
- } else {
- task.extra?.let {
- Text(it, style = MaterialTheme.typography.bodySmall)
- }
- }
- }
- }
- }
-
- Column {
- if (pendingTask != null && !taskStatus.isFinalStage()) {
- FilledIconButton(onClick = {
- runCatching {
- pendingTask.cancel()
- }.onFailure { throwable ->
- context.log.error("Failed to cancel task $pendingTask", throwable)
- }
- }) {
- Icon(Icons.Filled.Close, contentDescription = "Cancel")
- }
- } else {
- when (taskStatus) {
- TaskStatus.SUCCESS -> Icon(Icons.Filled.Check, contentDescription = "Success", tint = MaterialTheme.colorScheme.primary)
- TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = MaterialTheme.colorScheme.error)
- TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = MaterialTheme.colorScheme.error)
- else -> {}
- }
- }
- }
- }
- }
- }
-
- override val content: @Composable (NavBackStackEntry) -> Unit = {
- val scrollState = rememberLazyListState()
- val scope = rememberCoroutineScope()
- recentTasks = remember { mutableStateListOf() }
- var lastFetchedTaskId: Long? by remember { mutableStateOf(null) }
-
- fun fetchNewRecentTasks() {
- scope.launch(Dispatchers.IO) {
- val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE)
- if (tasks.isNotEmpty()) {
- lastFetchedTaskId = tasks.keys.last()
- val activeTaskIds = activeTasks.map { it.taskId }
- recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values)
- }
- }
- }
-
- LaunchedEffect(Unit) {
- fetchActiveTasks(this)
- }
-
- DisposableEffect(Unit) {
- onDispose {
- taskSelection.clear()
- }
- }
-
- OnLifecycleEvent { _, event ->
- if (event == Lifecycle.Event.ON_RESUME) {
- fetchActiveTasks(scope)
- }
- }
-
- LazyColumn(
- state = scrollState,
- modifier = Modifier.fillMaxSize()
- ) {
- item {
- if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- translation["no_tasks"].let {
- Icon(Icons.Filled.CheckCircle, contentDescription = it, tint = MaterialTheme.colorScheme.primary)
- Text(it, style = MaterialTheme.typography.bodyLarge)
- }
- }
- }
- }
- items(activeTasks, key = { it.taskId }) {pendingTask ->
- TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask)
- }
- items(recentTasks, key = { it.hash }) { task ->
- TaskCard(modifier = Modifier.padding(8.dp), task)
- }
- item {
- Spacer(modifier = Modifier.height(20.dp))
- LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
- fetchNewRecentTasks()
- }
- }
- }
- }
-}-
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt
@@ -0,0 +1,474 @@
+package me.rhunk.snapenhance.ui.manager.pages
+
+ import android.content.Intent
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.Lifecycle
+import androidx.navigation.NavBackStackEntry
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import me.rhunk.snapenhance.bridge.DownloadCallback
+import me.rhunk.snapenhance.common.data.download.DownloadMetadata
+import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
+import me.rhunk.snapenhance.common.data.download.createNewFilePath
+import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
+import me.rhunk.snapenhance.common.util.ktx.longHashCode
+import me.rhunk.snapenhance.download.DownloadProcessor
+import me.rhunk.snapenhance.download.FFMpegProcessor
+import me.rhunk.snapenhance.task.PendingTask
+import me.rhunk.snapenhance.task.PendingTaskListener
+import me.rhunk.snapenhance.task.Task
+import me.rhunk.snapenhance.task.TaskStatus
+import me.rhunk.snapenhance.task.TaskType
+import me.rhunk.snapenhance.ui.manager.Routes
+import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
+import java.io.File
+import java.util.UUID
+import kotlin.math.absoluteValue
+
+class TasksRootSection : Routes.Route() {
+ private var activeTasks by mutableStateOf(listOf<PendingTask>())
+ private lateinit var recentTasks: MutableList<Task>
+ private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
+
+ private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) {
+ scope.launch(Dispatchers.IO) {
+ activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList()
+ }
+ }
+
+ private fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) {
+ val firstTask = selection.first().first
+
+ val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16)
+ val pendingTask = context.taskManager.createPendingTask(
+ Task(TaskType.DOWNLOAD, "Merge ${selection.size} files", firstTask.author, taskHash)
+ )
+ pendingTask.status = TaskStatus.RUNNING
+ fetchActiveTasks()
+
+ context.coroutineScope.launch {
+ val filesToMerge = mutableListOf<File>()
+
+ selection.forEach { (task, documentFile) ->
+ val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also {
+ it.deleteOnExit()
+ }
+
+ runCatching {
+ pendingTask.updateProgress("Copying ${documentFile.name}")
+ context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
+ //copy with progress
+ val length = documentFile.length().toFloat()
+ tempFile.outputStream().use { outputStream ->
+ val buffer = ByteArray(16 * 1024)
+ var read: Int
+ while (inputStream.read(buffer).also { read = it } != -1) {
+ outputStream.write(buffer, 0, read)
+ pendingTask.updateProgress("Copying ${documentFile.name}", (outputStream.channel.position().toFloat() / length * 100f).toInt())
+ }
+ outputStream.flush()
+ filesToMerge.add(tempFile)
+ }
+ }
+ }.onFailure {
+ pendingTask.fail("Failed to copy file $documentFile to $tempFile")
+ filesToMerge.forEach { it.delete() }
+ return@launch
+ }
+ }
+
+ val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also {
+ it.deleteOnExit()
+ }
+
+ runCatching {
+ context.shortToast(translation.format("merge_files_toast", "count" to filesToMerge.size.toString()))
+ FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
+ FFMpegProcessor.Request(FFMpegProcessor.Action.MERGE_MEDIA, filesToMerge.map { it.absolutePath }, mergedFile)
+ )
+ DownloadProcessor(context, object: DownloadCallback.Default() {
+ override fun onSuccess(outputPath: String) {
+ context.log.verbose("Merged files to $outputPath")
+ }
+ }).saveMediaToGallery(pendingTask, mergedFile, DownloadMetadata(
+ mediaIdentifier = taskHash,
+ outputPath = createNewFilePath(
+ context.config.root,
+ taskHash,
+ downloadSource = MediaDownloadSource.MERGED,
+ mediaAuthor = firstTask.author,
+ creationTimestamp = System.currentTimeMillis()
+ ),
+ mediaAuthor = firstTask.author,
+ downloadSource = MediaDownloadSource.MERGED.translate(context.translation),
+ iconUrl = null
+ ))
+ }.onFailure {
+ context.log.error("Failed to merge files", it)
+ pendingTask.fail(it.message ?: "Failed to merge files")
+ }.onSuccess {
+ pendingTask.success()
+ }
+ filesToMerge.forEach { it.delete() }
+ mergedFile.delete()
+ }.also {
+ pendingTask.addListener(PendingTaskListener(onCancel = { it.cancel() }))
+ }
+ }
+
+ override val topBarActions: @Composable (RowScope.() -> Unit) = {
+ var showConfirmDialog by remember { mutableStateOf(false) }
+ val coroutineScope = rememberCoroutineScope()
+
+ if (taskSelection.size == 1) {
+ val selectionExists by rememberAsyncMutableState(defaultValue = false) {
+ taskSelection.firstOrNull()?.second?.exists() == true
+ }
+ if (selectionExists) {
+ taskSelection.firstOrNull()?.second?.let { documentFile ->
+ IconButton(onClick = {
+ runCatching {
+ context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(documentFile.uri, documentFile.type)
+ flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
+ })
+ taskSelection.clear()
+ }.onFailure {
+ context.log.error("Failed to open file ${taskSelection.first().second}", it)
+ }
+ }) {
+ Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Open")
+ }
+ }
+ }
+ }
+
+ if (taskSelection.size > 1) {
+ val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
+ taskSelection.all { it.second?.type?.contains("video") == true }
+ }
+
+ if (canMergeSelection) {
+ IconButton(onClick = {
+ mergeSelection(taskSelection.toList().also {
+ taskSelection.clear()
+ }.map { it.first to it.second!! })
+ }) {
+ Icon(Icons.Filled.Merge, contentDescription = "Merge")
+ }
+ }
+ }
+
+ IconButton(onClick = {
+ showConfirmDialog = true
+ }) {
+ Icon(Icons.Filled.Delete, contentDescription = "Clear tasks")
+ }
+
+ if (showConfirmDialog) {
+ var alsoDeleteFiles by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = { showConfirmDialog = false },
+ title = {
+ if (taskSelection.isNotEmpty()) {
+ Text(translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString()))
+ } else {
+ Text(translation["remove_all_tasks_confirm"])
+ }
+ },
+ text = {
+ Column {
+ if (taskSelection.isNotEmpty()) {
+ Text(translation["remove_selected_tasks_title"])
+ Row (
+ modifier = Modifier
+ .padding(top = 10.dp)
+ .fillMaxWidth()
+ .clickable {
+ alsoDeleteFiles = !alsoDeleteFiles
+ },
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(checked = alsoDeleteFiles, onCheckedChange = {
+ alsoDeleteFiles = it
+ })
+ Text(translation["delete_files_option"])
+ }
+ } else {
+ Text(translation["remove_all_tasks_title"])
+ }
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ showConfirmDialog = false
+ if (taskSelection.isNotEmpty()) {
+ taskSelection.forEach { (task, documentFile) ->
+ coroutineScope.launch(Dispatchers.IO) {
+ context.taskManager.removeTask(task)
+ if (alsoDeleteFiles) {
+ documentFile?.delete()
+ }
+ }
+ recentTasks.remove(task)
+ }
+ activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) }
+ taskSelection.clear()
+ } else {
+ coroutineScope.launch(Dispatchers.IO) {
+ context.taskManager.clearAllTasks()
+ }
+ recentTasks.clear()
+ activeTasks.forEach {
+ runCatching {
+ it.cancel()
+ }.onFailure { throwable ->
+ context.log.error("Failed to cancel task $it", throwable)
+ }
+ }
+ activeTasks = listOf()
+ context.taskManager.getActiveTasks().clear()
+ }
+ }
+ ) {
+ Text(context.translation["button.positive"])
+ }
+ },
+ dismissButton = {
+ Button(
+ onClick = {
+ showConfirmDialog = false
+ }
+ ) {
+ Text(context.translation["button.negative"])
+ }
+ }
+ )
+ }
+ }
+
+ @Composable
+ private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
+ var taskStatus by remember { mutableStateOf(task.status) }
+ var taskProgressLabel by remember { mutableStateOf<String?>(null) }
+ var taskProgress by remember { mutableIntStateOf(-1) }
+ val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
+
+ var documentFileMimeType by remember { mutableStateOf("") }
+ var isDocumentFileReadable by remember { mutableStateOf(true) }
+ val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) {
+ DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
+ documentFileMimeType = type ?: ""
+ isDocumentFileReadable = canRead()
+ }
+ }
+
+
+ val listener = remember { PendingTaskListener(
+ onStateChange = {
+ taskStatus = it
+ },
+ onProgress = { label, progress ->
+ taskProgressLabel = label
+ taskProgress = progress
+ }
+ ) }
+
+ LaunchedEffect(Unit) {
+ pendingTask?.addListener(listener)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ pendingTask?.removeListener(listener)
+ }
+ }
+
+ OutlinedCard(modifier = modifier
+ .clickable {
+ if (isSelected) {
+ taskSelection.removeIf { it.first == task }
+ return@clickable
+ }
+ taskSelection.add(task to documentFile)
+ }
+ .let {
+ if (isSelected) {
+ it
+ .border(2.dp, MaterialTheme.colorScheme.primary)
+ .clip(MaterialTheme.shapes.medium)
+ } else it
+ }) {
+ Row(
+ modifier = Modifier.padding(15.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier.padding(end = 15.dp)
+ ) {
+ documentFile?.let {
+ when {
+ !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
+ documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
+ documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
+ documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
+ else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
+ }
+ } ?: run {
+ when (task.type) {
+ TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download")
+ TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action")
+ }
+ }
+ }
+ Column(
+ modifier = Modifier.weight(1f),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(task.title, style = MaterialTheme.typography.bodyMedium)
+ task.author?.takeIf { it != "null" }?.let {
+ Spacer(modifier = Modifier.width(5.dp))
+ Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ Text(task.hash, style = MaterialTheme.typography.labelSmall)
+ Column(
+ modifier = Modifier.padding(top = 5.dp),
+ verticalArrangement = Arrangement.spacedBy(5.dp)
+ ) {
+ if (taskStatus.isFinalStage()) {
+ if (taskStatus != TaskStatus.SUCCESS) {
+ Text("$taskStatus", style = MaterialTheme.typography.bodySmall)
+ }
+ } else {
+ taskProgressLabel?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall)
+ }
+ if (taskProgress != -1) {
+ LinearProgressIndicator(
+ progress = { taskProgress.toFloat() / 100f },
+ strokeCap = StrokeCap.Round,
+ )
+ } else {
+ task.extra?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ }
+ }
+
+ Column {
+ if (pendingTask != null && !taskStatus.isFinalStage()) {
+ FilledIconButton(onClick = {
+ runCatching {
+ pendingTask.cancel()
+ }.onFailure { throwable ->
+ context.log.error("Failed to cancel task $pendingTask", throwable)
+ }
+ }) {
+ Icon(Icons.Filled.Close, contentDescription = "Cancel")
+ }
+ } else {
+ when (taskStatus) {
+ TaskStatus.SUCCESS -> Icon(Icons.Filled.Check, contentDescription = "Success", tint = MaterialTheme.colorScheme.primary)
+ TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = MaterialTheme.colorScheme.error)
+ TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = MaterialTheme.colorScheme.error)
+ else -> {}
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override val content: @Composable (NavBackStackEntry) -> Unit = {
+ val scrollState = rememberLazyListState()
+ val scope = rememberCoroutineScope()
+ recentTasks = remember { mutableStateListOf() }
+ var lastFetchedTaskId: Long? by remember { mutableStateOf(null) }
+
+ fun fetchNewRecentTasks() {
+ scope.launch(Dispatchers.IO) {
+ val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE)
+ if (tasks.isNotEmpty()) {
+ lastFetchedTaskId = tasks.keys.last()
+ val activeTaskIds = activeTasks.map { it.taskId }
+ recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values)
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ fetchActiveTasks(this)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ taskSelection.clear()
+ }
+ }
+
+ OnLifecycleEvent { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ fetchActiveTasks(scope)
+ }
+ }
+
+ LazyColumn(
+ state = scrollState,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ item {
+ if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ translation["no_tasks"].let {
+ Icon(Icons.Filled.CheckCircle, contentDescription = it, tint = MaterialTheme.colorScheme.primary)
+ Text(it, style = MaterialTheme.typography.bodyLarge)
+ }
+ }
+ }
+ }
+ items(activeTasks, key = { it.taskId }) {pendingTask ->
+ TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask)
+ }
+ items(recentTasks, key = { it.hash }) { task ->
+ TaskCard(modifier = Modifier.padding(8.dp), task)
+ }
+ item {
+ Spacer(modifier = Modifier.height(20.dp))
+ LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
+ fetchNewRecentTasks()
+ }
+ }
+ }
+ }
+}+
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt
@@ -1,707 +0,0 @@
-package me.rhunk.snapenhance.ui.manager.pages.features
-
-import android.content.Intent
-import android.net.Uri
-import androidx.compose.animation.AnimatedContentTransitionScope
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.OpenInNew
-import androidx.compose.material.icons.filled.*
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.lifecycle.Lifecycle
-import androidx.navigation.NavBackStackEntry
-import androidx.navigation.NavGraph.Companion.findStartDestination
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavOptions
-import androidx.navigation.compose.composable
-import com.github.skydoves.colorpicker.compose.AlphaTile
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import me.rhunk.snapenhance.common.config.*
-import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
-import me.rhunk.snapenhance.ui.manager.MainActivity
-import me.rhunk.snapenhance.ui.manager.Routes
-import me.rhunk.snapenhance.ui.util.*
-
-class FeaturesRoot : Routes.Route() {
- private val alertDialogs by lazy { AlertDialogs(context.translation) }
-
- companion object {
- const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}"
- const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}"
- }
-
- private var activityLauncherHelper: ActivityLauncherHelper? = null
-
- private val allContainers by lazy {
- val containers = mutableMapOf<String, PropertyPair<*>>()
- fun queryContainerRecursive(container: ConfigContainer) {
- container.properties.forEach {
- if (it.key.dataType.type == DataProcessors.Type.CONTAINER) {
- containers[it.key.name] = PropertyPair(it.key, it.value)
- queryContainerRecursive(it.value.get() as ConfigContainer)
- }
- }
- }
- queryContainerRecursive(context.config.root)
- containers
- }
-
- private val allProperties by lazy {
- val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>()
- allContainers.values.forEach {
- val container = it.value.get() as ConfigContainer
- container.properties.forEach { property ->
- properties[property.key] = property.value
- }
- }
- properties
- }
-
- private fun navigateToMainRoot() {
- routes.navController.navigate(routeInfo.id, NavOptions.Builder()
- .setPopUpTo(routes.navController.graph.findStartDestination().id, false)
- .setLaunchSingleTop(true)
- .build()
- )
- }
-
- override val init: () -> Unit = {
- activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
- }
-
- private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) {
- activityLauncherHelper?.let(block) ?: run {
- //open manager if activity launcher is null
- val intent = Intent(context.androidContext, MainActivity::class.java)
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- intent.putExtra("route", routeInfo.id)
- context.androidContext.startActivity(intent)
- }
- }
-
- override val content: @Composable (NavBackStackEntry) -> Unit = {
- Container(context.config.root)
- }
-
- override val customComposables: NavGraphBuilder.() -> Unit = {
- routeInfo.childIds.addAll(listOf(FEATURE_CONTAINER_ROUTE, SEARCH_FEATURE_ROUTE))
-
- composable(FEATURE_CONTAINER_ROUTE, enterTransition = {
- slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100))
- }, exitTransition = {
- slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300))
- }) { backStackEntry ->
- backStackEntry.arguments?.getString("name")?.let { containerName ->
- allContainers[containerName]?.let {
- Container(it.value.get() as ConfigContainer)
- }
- }
- }
-
- composable(SEARCH_FEATURE_ROUTE) { backStackEntry ->
- backStackEntry.arguments?.getString("keyword")?.let { keyword ->
- val properties = allProperties.filter {
- it.key.name.contains(keyword, ignoreCase = true) ||
- context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
- context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
- }.map { PropertyPair(it.key, it.value) }
-
- PropertiesView(properties)
- }
- }
- }
-
- @Composable
- private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) {
- var showDialog by remember { mutableStateOf(false) }
- var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
-
- fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true }
-
- if (showDialog) {
- Dialog(
- properties = DialogProperties(
- usePlatformDefaultWidth = false
- ),
- onDismissRequest = { showDialog = false },
- ) {
- dialogComposable()
- }
- }
-
- val propertyValue = property.value
-
- if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
- registerDialogOnClickCallback()
- dialogComposable = {
- var isEmpty by remember { mutableStateOf(false) }
- val files = rememberAsyncMutableStateList(defaultValue = listOf()) {
- context.fileHandleManager.getStoredFiles {
- property.key.params.filenameFilter?.invoke(it.name) == true
- }.also {
- isEmpty = it.isEmpty()
- if (isEmpty) {
- propertyValue.setAny(null)
- }
- }
- }
- var selectedFile by remember(files.size) { mutableStateOf(files.firstOrNull { it.name == propertyValue.getNullable() }.also {
- if (files.isNotEmpty() && it == null) propertyValue.setAny(null)
- }?.name) }
-
- Card(
- shape = MaterialTheme.shapes.large,
- modifier = Modifier
- .fillMaxWidth(),
- ) {
- LazyColumn(
- modifier = Modifier.fillMaxWidth().padding(4.dp),
- ) {
- item {
- Column(
- modifier = Modifier.fillMaxWidth().padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"],
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- )
- if (isEmpty) {
- Text(
- text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"],
- fontSize = 16.sp,
- modifier = Modifier.padding(top = 10.dp),
- )
- }
- }
- }
- items(files, key = { it.name }) { file ->
- Row(
- modifier = Modifier.clickable {
- selectedFile = if (selectedFile == file.name) null else file.name
- propertyValue.setAny(selectedFile)
- }.padding(5.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(Icons.Filled.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp))
- Text(
- text = file.name,
- modifier = Modifier
- .padding(3.dp)
- .weight(1f),
- fontSize = 14.sp,
- lineHeight = 16.sp
- )
- if (selectedFile == file.name) {
- Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(5.dp))
- }
- }
- }
- }
- }
- }
-
- Icon(Icons.Filled.AttachFile, contentDescription = null)
- return
- }
-
- if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
- IconButton(onClick = registerClickCallback {
- activityLauncher {
- chooseFolder { uri ->
- propertyValue.setAny(uri)
- }
- }
- }.let { { it.invoke(true) } }) {
- Icon(Icons.Filled.FolderOpen, contentDescription = null)
- }
- return
- }
-
- when (val dataType = remember { property.key.dataType.type }) {
- DataProcessors.Type.BOOLEAN -> {
- var state by remember { mutableStateOf(propertyValue.get() as Boolean) }
- Switch(
- checked = state,
- onCheckedChange = registerClickCallback {
- state = state.not()
- propertyValue.setAny(state)
- }
- )
- }
-
- DataProcessors.Type.MAP_COORDINATES -> {
- registerDialogOnClickCallback()
- dialogComposable = {
- alertDialogs.ChooseLocationDialog(property) {
- showDialog = false
- }
- }
-
- Text(
- overflow = TextOverflow.Ellipsis,
- maxLines = 1,
- modifier = Modifier.widthIn(0.dp, 120.dp),
- text = (propertyValue.get() as Pair<*, *>).let {
- "${it.first.toString().toFloatOrNull() ?: 0F}, ${it.second.toString().toFloatOrNull() ?: 0F}"
- }
- )
- }
-
- DataProcessors.Type.STRING_UNIQUE_SELECTION -> {
- registerDialogOnClickCallback()
-
- dialogComposable = {
- alertDialogs.UniqueSelectionDialog(property)
- }
-
- Text(
- overflow = TextOverflow.Ellipsis,
- maxLines = 1,
- modifier = Modifier.widthIn(0.dp, 120.dp),
- text = (propertyValue.getNullable() as? String ?: "null").let {
- property.key.propertyOption(context.translation, it)
- }
- )
- }
-
- DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
- dialogComposable = {
- when (dataType) {
- DataProcessors.Type.STRING_MULTIPLE_SELECTION -> {
- alertDialogs.MultipleSelectionDialog(property)
- }
- DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
- alertDialogs.KeyboardInputDialog(property) { showDialog = false }
- }
- else -> {}
- }
- }
-
- registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
- if (dataType == DataProcessors.Type.INTEGER ||
- dataType == DataProcessors.Type.FLOAT) {
- FilledIconButton(onClick = it) {
- Text(
- text = propertyValue.get().toString(),
- modifier = Modifier.wrapContentWidth(),
- overflow = TextOverflow.Ellipsis
- )
- }
- } else {
- IconButton(onClick = it) {
- Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
- }
- }
- }
- }
-
- DataProcessors.Type.INT_COLOR -> {
- dialogComposable = {
- alertDialogs.ColorPickerDialog(property) {
- showDialog = false
- }
- }
-
- registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
- val selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) }
- AlphaTile(
- modifier = Modifier
- .size(30.dp)
- .border(2.dp, Color.White, shape = RoundedCornerShape(15.dp))
- .clip(RoundedCornerShape(15.dp)),
- selectedColor = selectedColor ?: Color.Transparent,
- tileEvenColor = selectedColor?.let { Color(0xFFCBCBCB) } ?: Color.Transparent,
- tileOddColor = selectedColor?.let { Color.White } ?: Color.Transparent,
- tileSize = 8.dp,
- )
- }
- }
-
- DataProcessors.Type.CONTAINER -> {
- val container = propertyValue.get() as ConfigContainer
-
- registerClickCallback {
- routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name))
- }
-
- if (!container.hasGlobalState) return
-
- var state by remember { mutableStateOf(container.globalState ?: false) }
-
- Box(
- modifier = Modifier
- .padding(end = 15.dp),
- ) {
-
- Box(modifier = Modifier
- .height(50.dp)
- .width(1.dp)
- .background(
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
- shape = RoundedCornerShape(5.dp)
- ))
- }
-
- Switch(
- checked = state,
- onCheckedChange = {
- state = state.not()
- container.globalState = state
- }
- )
- }
- }
-
- }
-
- @Composable
- private fun PropertyCard(property: PropertyPair<*>) {
- var clickCallback by remember { mutableStateOf<ClickCallback?>(null) }
- val noticeColorMap = mapOf(
- FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87),
- FeatureNotice.BAN_RISK.key to Color(0xFFFF8585),
- FeatureNotice.INTERNAL_BEHAVIOR.key to Color(0xFFFFFB87),
- FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722),
- )
-
- ElevatedCard(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp)
- ) {
- Row(
- modifier = Modifier
- .fillMaxSize()
- .clickable {
- clickCallback?.invoke(true)
- }
- .padding(all = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- property.key.params.icon?.let { icon ->
- Icon(
- imageVector = icon,
- contentDescription = null,
- modifier = Modifier
- .align(Alignment.CenterVertically)
- .padding(start = 10.dp)
- )
- }
-
- Column(
- modifier = Modifier
- .align(Alignment.CenterVertically)
- .weight(1f, fill = true)
- .padding(all = 10.dp)
- ) {
- Text(
- text = context.translation[property.key.propertyName()],
- fontSize = 16.sp,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = context.translation[property.key.propertyDescription()],
- fontSize = 12.sp,
- lineHeight = 15.sp
- )
- property.key.params.notices.also {
- if (it.isNotEmpty()) Spacer(modifier = Modifier.height(5.dp))
- }.forEach {
- Text(
- text = context.translation["features.notices.${it.key}"],
- color = noticeColorMap[it.key] ?: Color(0xFFFFFB87),
- fontSize = 12.sp,
- lineHeight = 15.sp
- )
- }
- }
- Row(
- modifier = Modifier
- .align(Alignment.CenterVertically)
- .padding(all = 10.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- PropertyAction(property, registerClickCallback = { callback ->
- clickCallback = callback
- callback
- })
- }
- }
- }
- }
-
- @Composable
- private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) {
- var searchValue by remember { mutableStateOf("") }
- val scope = rememberCoroutineScope()
- var currentSearchJob by remember { mutableStateOf<Job?>(null) }
-
- rowScope.apply {
- TextField(
- value = searchValue,
- onValueChange = { keyword ->
- searchValue = keyword
- if (keyword.isEmpty()) {
- navigateToMainRoot()
- return@TextField
- }
- currentSearchJob?.cancel()
- scope.launch {
- delay(150)
- routes.navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder()
- .setLaunchSingleTop(true)
- .setPopUpTo(routeInfo.id, false)
- .build()
- )
- }.also { currentSearchJob = it }
- },
-
- keyboardActions = KeyboardActions(onDone = {
- focusRequester.freeFocus()
- }),
- modifier = Modifier
- .focusRequester(focusRequester)
- .weight(1f, fill = true)
- .padding(end = 10.dp)
- .height(70.dp),
- singleLine = true,
- colors = TextFieldDefaults.colors(
- unfocusedContainerColor = MaterialTheme.colorScheme.surface,
- focusedContainerColor = MaterialTheme.colorScheme.surface,
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- disabledIndicatorColor = Color.Transparent,
- cursorColor = MaterialTheme.colorScheme.primary
- )
- )
- }
- }
-
- override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
- var showSearchBar by remember { mutableStateOf(false) }
- val focusRequester = remember { FocusRequester() }
-
- if (showSearchBar) {
- FeatureSearchBar(this, focusRequester)
- LaunchedEffect(true) {
- focusRequester.requestFocus()
- }
- }
-
- IconButton(onClick = {
- showSearchBar = showSearchBar.not()
- if (!showSearchBar && routes.currentDestination == SEARCH_FEATURE_ROUTE) {
- navigateToMainRoot()
- }
- }) {
- Icon(
- imageVector = if (showSearchBar) Icons.Filled.Close
- else Icons.Filled.Search,
- contentDescription = null
- )
- }
-
- if (showSearchBar) return@topBarActions
-
- var showExportDropdownMenu by remember { mutableStateOf(false) }
- var showResetConfirmationDialog by remember { mutableStateOf(false) }
- var showExportDialog by remember { mutableStateOf(false) }
-
- if (showResetConfirmationDialog) {
- AlertDialog(
- title = { Text(text = context.translation["manager.dialogs.reset_config.title"]) },
- text = { Text(text = context.translation["manager.dialogs.reset_config.content"]) },
- onDismissRequest = { showResetConfirmationDialog = false },
- confirmButton = {
- Button(
- onClick = {
- context.config.reset()
- context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"])
- showResetConfirmationDialog = false
- }
- ) {
- Text(text = context.translation["button.positive"])
- }
- },
- dismissButton = {
- Button(
- onClick = {
- showResetConfirmationDialog = false
- }
- ) {
- Text(text = context.translation["button.negative"])
- }
- }
- )
- }
-
- if (showExportDialog) {
- fun exportConfig(
- exportSensitiveData: Boolean
- ) {
- showExportDialog = false
- activityLauncher {
- saveFile("config.json", "application/json") { uri ->
- runCatching {
- context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
- context.config.writeConfig()
- context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
- context.shortToast(translation["config_export_success_toast"])
- }
- }.onFailure {
- context.longToast(translation.format("config_export_failure_toast", "error" to it.message.toString()))
- }
- }
- }
- }
-
- AlertDialog(
- title = { Text(text = context.translation["manager.dialogs.export_config.title"]) },
- text = { Text(text = context.translation["manager.dialogs.export_config.content"]) },
- onDismissRequest = { showExportDialog = false },
- confirmButton = {
- Button(
- onClick = { exportConfig(true) }
- ) {
- Text(text = context.translation["button.positive"])
- }
- },
- dismissButton = {
- Button(
- onClick = { exportConfig(false) }
- ) {
- Text(text = context.translation["button.negative"])
- }
- }
- )
- }
-
- val actions = remember {
- mapOf(
- translation["export_option"] to { showExportDialog = true },
- translation["import_option"] to {
- activityLauncher {
- openFile("application/json") { uri ->
- context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use {
- runCatching {
- context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8))
- }.onFailure {
- context.longToast(translation.format("config_import_failure_toast", "error" to it.message.toString()))
- return@use
- }
- context.shortToast(translation["config_import_success_toast"])
- context.coroutineScope.launch(Dispatchers.Main) {
- navigateReload()
- }
- }
- }
- }
- },
- translation["reset_option"] to { showResetConfirmationDialog = true }
- )
- }
-
- if (context.activity != null) {
- IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) {
- Icon(
- imageVector = Icons.Filled.MoreVert,
- contentDescription = null
- )
- }
- }
-
- if (showExportDropdownMenu) {
- DropdownMenu(expanded = true, onDismissRequest = { showExportDropdownMenu = false }) {
- actions.forEach { (name, action) ->
- DropdownMenuItem(
- text = {
- Text(text = name)
- },
- onClick = {
- action()
- showExportDropdownMenu = false
- }
- )
- }
- }
- }
- }
-
- @Composable
- private fun PropertiesView(
- properties: List<PropertyPair<*>>
- ) {
- Scaffold(
- modifier = Modifier.fillMaxSize(),
- content = { innerPadding ->
- LazyColumn(
- modifier = Modifier
- .fillMaxHeight()
- .padding(innerPadding),
- //save button space
- contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp),
- verticalArrangement = Arrangement.Top
- ) {
- items(properties) {
- PropertyCard(it)
- }
- }
- }
- )
- }
-
- override val floatingActionButton: @Composable () -> Unit = {
- fun saveConfig() {
- context.coroutineScope.launch(Dispatchers.IO) {
- context.config.writeConfig()
- context.log.verbose("saved config!")
- }
- }
-
- OnLifecycleEvent { _, event ->
- if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
- saveConfig()
- }
- }
-
- DisposableEffect(Unit) {
- onDispose {
- saveConfig()
- }
- }
- }
-
-
- @Composable
- private fun Container(
- configContainer: ConfigContainer
- ) {
- PropertiesView(remember {
- configContainer.properties.map { PropertyPair(it.key, it.value) }
- })
- }
-}-
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt
@@ -0,0 +1,707 @@
+package me.rhunk.snapenhance.ui.manager.pages.features
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.Lifecycle
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.github.skydoves.colorpicker.compose.AlphaTile
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import me.rhunk.snapenhance.common.config.*
+import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
+import me.rhunk.snapenhance.ui.manager.MainActivity
+import me.rhunk.snapenhance.ui.manager.Routes
+import me.rhunk.snapenhance.ui.util.*
+
+class FeaturesRootSection : Routes.Route() {
+ private val alertDialogs by lazy { AlertDialogs(context.translation) }
+
+ companion object {
+ const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}"
+ const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}"
+ }
+
+ private var activityLauncherHelper: ActivityLauncherHelper? = null
+
+ private val allContainers by lazy {
+ val containers = mutableMapOf<String, PropertyPair<*>>()
+ fun queryContainerRecursive(container: ConfigContainer) {
+ container.properties.forEach {
+ if (it.key.dataType.type == DataProcessors.Type.CONTAINER) {
+ containers[it.key.name] = PropertyPair(it.key, it.value)
+ queryContainerRecursive(it.value.get() as ConfigContainer)
+ }
+ }
+ }
+ queryContainerRecursive(context.config.root)
+ containers
+ }
+
+ private val allProperties by lazy {
+ val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>()
+ allContainers.values.forEach {
+ val container = it.value.get() as ConfigContainer
+ container.properties.forEach { property ->
+ properties[property.key] = property.value
+ }
+ }
+ properties
+ }
+
+ private fun navigateToMainRoot() {
+ routes.navController.navigate(routeInfo.id, NavOptions.Builder()
+ .setPopUpTo(routes.navController.graph.findStartDestination().id, false)
+ .setLaunchSingleTop(true)
+ .build()
+ )
+ }
+
+ override val init: () -> Unit = {
+ activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
+ }
+
+ private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) {
+ activityLauncherHelper?.let(block) ?: run {
+ //open manager if activity launcher is null
+ val intent = Intent(context.androidContext, MainActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ intent.putExtra("route", routeInfo.id)
+ context.androidContext.startActivity(intent)
+ }
+ }
+
+ override val content: @Composable (NavBackStackEntry) -> Unit = {
+ Container(context.config.root)
+ }
+
+ override val customComposables: NavGraphBuilder.() -> Unit = {
+ routeInfo.childIds.addAll(listOf(FEATURE_CONTAINER_ROUTE, SEARCH_FEATURE_ROUTE))
+
+ composable(FEATURE_CONTAINER_ROUTE, enterTransition = {
+ slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100))
+ }, exitTransition = {
+ slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300))
+ }) { backStackEntry ->
+ backStackEntry.arguments?.getString("name")?.let { containerName ->
+ allContainers[containerName]?.let {
+ Container(it.value.get() as ConfigContainer)
+ }
+ }
+ }
+
+ composable(SEARCH_FEATURE_ROUTE) { backStackEntry ->
+ backStackEntry.arguments?.getString("keyword")?.let { keyword ->
+ val properties = allProperties.filter {
+ it.key.name.contains(keyword, ignoreCase = true) ||
+ context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
+ context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
+ }.map { PropertyPair(it.key, it.value) }
+
+ PropertiesView(properties)
+ }
+ }
+ }
+
+ @Composable
+ private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) {
+ var showDialog by remember { mutableStateOf(false) }
+ var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
+
+ fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true }
+
+ if (showDialog) {
+ Dialog(
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false
+ ),
+ onDismissRequest = { showDialog = false },
+ ) {
+ dialogComposable()
+ }
+ }
+
+ val propertyValue = property.value
+
+ if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
+ registerDialogOnClickCallback()
+ dialogComposable = {
+ var isEmpty by remember { mutableStateOf(false) }
+ val files = rememberAsyncMutableStateList(defaultValue = listOf()) {
+ context.fileHandleManager.getStoredFiles {
+ property.key.params.filenameFilter?.invoke(it.name) == true
+ }.also {
+ isEmpty = it.isEmpty()
+ if (isEmpty) {
+ propertyValue.setAny(null)
+ }
+ }
+ }
+ var selectedFile by remember(files.size) { mutableStateOf(files.firstOrNull { it.name == propertyValue.getNullable() }.also {
+ if (files.isNotEmpty() && it == null) propertyValue.setAny(null)
+ }?.name) }
+
+ Card(
+ shape = MaterialTheme.shapes.large,
+ modifier = Modifier
+ .fillMaxWidth(),
+ ) {
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().padding(4.dp),
+ ) {
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"],
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ if (isEmpty) {
+ Text(
+ text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"],
+ fontSize = 16.sp,
+ modifier = Modifier.padding(top = 10.dp),
+ )
+ }
+ }
+ }
+ items(files, key = { it.name }) { file ->
+ Row(
+ modifier = Modifier.clickable {
+ selectedFile = if (selectedFile == file.name) null else file.name
+ propertyValue.setAny(selectedFile)
+ }.padding(5.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Filled.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp))
+ Text(
+ text = file.name,
+ modifier = Modifier
+ .padding(3.dp)
+ .weight(1f),
+ fontSize = 14.sp,
+ lineHeight = 16.sp
+ )
+ if (selectedFile == file.name) {
+ Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(5.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Icon(Icons.Filled.AttachFile, contentDescription = null)
+ return
+ }
+
+ if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
+ IconButton(onClick = registerClickCallback {
+ activityLauncher {
+ chooseFolder { uri ->
+ propertyValue.setAny(uri)
+ }
+ }
+ }.let { { it.invoke(true) } }) {
+ Icon(Icons.Filled.FolderOpen, contentDescription = null)
+ }
+ return
+ }
+
+ when (val dataType = remember { property.key.dataType.type }) {
+ DataProcessors.Type.BOOLEAN -> {
+ var state by remember { mutableStateOf(propertyValue.get() as Boolean) }
+ Switch(
+ checked = state,
+ onCheckedChange = registerClickCallback {
+ state = state.not()
+ propertyValue.setAny(state)
+ }
+ )
+ }
+
+ DataProcessors.Type.MAP_COORDINATES -> {
+ registerDialogOnClickCallback()
+ dialogComposable = {
+ alertDialogs.ChooseLocationDialog(property) {
+ showDialog = false
+ }
+ }
+
+ Text(
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ modifier = Modifier.widthIn(0.dp, 120.dp),
+ text = (propertyValue.get() as Pair<*, *>).let {
+ "${it.first.toString().toFloatOrNull() ?: 0F}, ${it.second.toString().toFloatOrNull() ?: 0F}"
+ }
+ )
+ }
+
+ DataProcessors.Type.STRING_UNIQUE_SELECTION -> {
+ registerDialogOnClickCallback()
+
+ dialogComposable = {
+ alertDialogs.UniqueSelectionDialog(property)
+ }
+
+ Text(
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ modifier = Modifier.widthIn(0.dp, 120.dp),
+ text = (propertyValue.getNullable() as? String ?: "null").let {
+ property.key.propertyOption(context.translation, it)
+ }
+ )
+ }
+
+ DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
+ dialogComposable = {
+ when (dataType) {
+ DataProcessors.Type.STRING_MULTIPLE_SELECTION -> {
+ alertDialogs.MultipleSelectionDialog(property)
+ }
+ DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
+ alertDialogs.KeyboardInputDialog(property) { showDialog = false }
+ }
+ else -> {}
+ }
+ }
+
+ registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
+ if (dataType == DataProcessors.Type.INTEGER ||
+ dataType == DataProcessors.Type.FLOAT) {
+ FilledIconButton(onClick = it) {
+ Text(
+ text = propertyValue.get().toString(),
+ modifier = Modifier.wrapContentWidth(),
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ } else {
+ IconButton(onClick = it) {
+ Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
+ }
+ }
+ }
+ }
+
+ DataProcessors.Type.INT_COLOR -> {
+ dialogComposable = {
+ alertDialogs.ColorPickerDialog(property) {
+ showDialog = false
+ }
+ }
+
+ registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
+ val selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) }
+ AlphaTile(
+ modifier = Modifier
+ .size(30.dp)
+ .border(2.dp, Color.White, shape = RoundedCornerShape(15.dp))
+ .clip(RoundedCornerShape(15.dp)),
+ selectedColor = selectedColor ?: Color.Transparent,
+ tileEvenColor = selectedColor?.let { Color(0xFFCBCBCB) } ?: Color.Transparent,
+ tileOddColor = selectedColor?.let { Color.White } ?: Color.Transparent,
+ tileSize = 8.dp,
+ )
+ }
+ }
+
+ DataProcessors.Type.CONTAINER -> {
+ val container = propertyValue.get() as ConfigContainer
+
+ registerClickCallback {
+ routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name))
+ }
+
+ if (!container.hasGlobalState) return
+
+ var state by remember { mutableStateOf(container.globalState ?: false) }
+
+ Box(
+ modifier = Modifier
+ .padding(end = 15.dp),
+ ) {
+
+ Box(modifier = Modifier
+ .height(50.dp)
+ .width(1.dp)
+ .background(
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
+ shape = RoundedCornerShape(5.dp)
+ ))
+ }
+
+ Switch(
+ checked = state,
+ onCheckedChange = {
+ state = state.not()
+ container.globalState = state
+ }
+ )
+ }
+ }
+
+ }
+
+ @Composable
+ private fun PropertyCard(property: PropertyPair<*>) {
+ var clickCallback by remember { mutableStateOf<ClickCallback?>(null) }
+ val noticeColorMap = mapOf(
+ FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87),
+ FeatureNotice.BAN_RISK.key to Color(0xFFFF8585),
+ FeatureNotice.INTERNAL_BEHAVIOR.key to Color(0xFFFFFB87),
+ FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722),
+ )
+
+ ElevatedCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable {
+ clickCallback?.invoke(true)
+ }
+ .padding(all = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ property.key.params.icon?.let { icon ->
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .padding(start = 10.dp)
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .weight(1f, fill = true)
+ .padding(all = 10.dp)
+ ) {
+ Text(
+ text = context.translation[property.key.propertyName()],
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = context.translation[property.key.propertyDescription()],
+ fontSize = 12.sp,
+ lineHeight = 15.sp
+ )
+ property.key.params.notices.also {
+ if (it.isNotEmpty()) Spacer(modifier = Modifier.height(5.dp))
+ }.forEach {
+ Text(
+ text = context.translation["features.notices.${it.key}"],
+ color = noticeColorMap[it.key] ?: Color(0xFFFFFB87),
+ fontSize = 12.sp,
+ lineHeight = 15.sp
+ )
+ }
+ }
+ Row(
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .padding(all = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ PropertyAction(property, registerClickCallback = { callback ->
+ clickCallback = callback
+ callback
+ })
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) {
+ var searchValue by remember { mutableStateOf("") }
+ val scope = rememberCoroutineScope()
+ var currentSearchJob by remember { mutableStateOf<Job?>(null) }
+
+ rowScope.apply {
+ TextField(
+ value = searchValue,
+ onValueChange = { keyword ->
+ searchValue = keyword
+ if (keyword.isEmpty()) {
+ navigateToMainRoot()
+ return@TextField
+ }
+ currentSearchJob?.cancel()
+ scope.launch {
+ delay(150)
+ routes.navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder()
+ .setLaunchSingleTop(true)
+ .setPopUpTo(routeInfo.id, false)
+ .build()
+ )
+ }.also { currentSearchJob = it }
+ },
+
+ keyboardActions = KeyboardActions(onDone = {
+ focusRequester.freeFocus()
+ }),
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .weight(1f, fill = true)
+ .padding(end = 10.dp)
+ .height(70.dp),
+ singleLine = true,
+ colors = TextFieldDefaults.colors(
+ unfocusedContainerColor = MaterialTheme.colorScheme.surface,
+ focusedContainerColor = MaterialTheme.colorScheme.surface,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ cursorColor = MaterialTheme.colorScheme.primary
+ )
+ )
+ }
+ }
+
+ override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
+ var showSearchBar by remember { mutableStateOf(false) }
+ val focusRequester = remember { FocusRequester() }
+
+ if (showSearchBar) {
+ FeatureSearchBar(this, focusRequester)
+ LaunchedEffect(true) {
+ focusRequester.requestFocus()
+ }
+ }
+
+ IconButton(onClick = {
+ showSearchBar = showSearchBar.not()
+ if (!showSearchBar && routes.currentDestination == SEARCH_FEATURE_ROUTE) {
+ navigateToMainRoot()
+ }
+ }) {
+ Icon(
+ imageVector = if (showSearchBar) Icons.Filled.Close
+ else Icons.Filled.Search,
+ contentDescription = null
+ )
+ }
+
+ if (showSearchBar) return@topBarActions
+
+ var showExportDropdownMenu by remember { mutableStateOf(false) }
+ var showResetConfirmationDialog by remember { mutableStateOf(false) }
+ var showExportDialog by remember { mutableStateOf(false) }
+
+ if (showResetConfirmationDialog) {
+ AlertDialog(
+ title = { Text(text = context.translation["manager.dialogs.reset_config.title"]) },
+ text = { Text(text = context.translation["manager.dialogs.reset_config.content"]) },
+ onDismissRequest = { showResetConfirmationDialog = false },
+ confirmButton = {
+ Button(
+ onClick = {
+ context.config.reset()
+ context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"])
+ showResetConfirmationDialog = false
+ }
+ ) {
+ Text(text = context.translation["button.positive"])
+ }
+ },
+ dismissButton = {
+ Button(
+ onClick = {
+ showResetConfirmationDialog = false
+ }
+ ) {
+ Text(text = context.translation["button.negative"])
+ }
+ }
+ )
+ }
+
+ if (showExportDialog) {
+ fun exportConfig(
+ exportSensitiveData: Boolean
+ ) {
+ showExportDialog = false
+ activityLauncher {
+ saveFile("config.json", "application/json") { uri ->
+ runCatching {
+ context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
+ context.config.writeConfig()
+ context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
+ context.shortToast(translation["config_export_success_toast"])
+ }
+ }.onFailure {
+ context.longToast(translation.format("config_export_failure_toast", "error" to it.message.toString()))
+ }
+ }
+ }
+ }
+
+ AlertDialog(
+ title = { Text(text = context.translation["manager.dialogs.export_config.title"]) },
+ text = { Text(text = context.translation["manager.dialogs.export_config.content"]) },
+ onDismissRequest = { showExportDialog = false },
+ confirmButton = {
+ Button(
+ onClick = { exportConfig(true) }
+ ) {
+ Text(text = context.translation["button.positive"])
+ }
+ },
+ dismissButton = {
+ Button(
+ onClick = { exportConfig(false) }
+ ) {
+ Text(text = context.translation["button.negative"])
+ }
+ }
+ )
+ }
+
+ val actions = remember {
+ mapOf(
+ translation["export_option"] to { showExportDialog = true },
+ translation["import_option"] to {
+ activityLauncher {
+ openFile("application/json") { uri ->
+ context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use {
+ runCatching {
+ context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8))
+ }.onFailure {
+ context.longToast(translation.format("config_import_failure_toast", "error" to it.message.toString()))
+ return@use
+ }
+ context.shortToast(translation["config_import_success_toast"])
+ context.coroutineScope.launch(Dispatchers.Main) {
+ navigateReload()
+ }
+ }
+ }
+ }
+ },
+ translation["reset_option"] to { showResetConfirmationDialog = true }
+ )
+ }
+
+ if (context.activity != null) {
+ IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) {
+ Icon(
+ imageVector = Icons.Filled.MoreVert,
+ contentDescription = null
+ )
+ }
+ }
+
+ if (showExportDropdownMenu) {
+ DropdownMenu(expanded = true, onDismissRequest = { showExportDropdownMenu = false }) {
+ actions.forEach { (name, action) ->
+ DropdownMenuItem(
+ text = {
+ Text(text = name)
+ },
+ onClick = {
+ action()
+ showExportDropdownMenu = false
+ }
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun PropertiesView(
+ properties: List<PropertyPair<*>>
+ ) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ content = { innerPadding ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(innerPadding),
+ //save button space
+ contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp),
+ verticalArrangement = Arrangement.Top
+ ) {
+ items(properties) {
+ PropertyCard(it)
+ }
+ }
+ }
+ )
+ }
+
+ override val floatingActionButton: @Composable () -> Unit = {
+ fun saveConfig() {
+ context.coroutineScope.launch(Dispatchers.IO) {
+ context.config.writeConfig()
+ context.log.verbose("saved config!")
+ }
+ }
+
+ OnLifecycleEvent { _, event ->
+ if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
+ saveConfig()
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ saveConfig()
+ }
+ }
+ }
+
+
+ @Composable
+ private fun Container(
+ configContainer: ConfigContainer
+ ) {
+ PropertiesView(remember {
+ configContainer.properties.map { PropertyPair(it.key, it.value) }
+ })
+ }
+}+
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt
@@ -1,439 +0,0 @@
-package me.rhunk.snapenhance.ui.manager.pages.home
-
-import android.content.Intent
-import android.net.Uri
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.ClickableText
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.Help
-import androidx.compose.material.icons.filled.BugReport
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.res.vectorResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.Font
-import androidx.compose.ui.text.font.FontFamily
-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.text.withStyle
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.navigation.NavBackStackEntry
-import kotlinx.coroutines.launch
-import me.rhunk.snapenhance.R
-import me.rhunk.snapenhance.action.EnumQuickActions
-import me.rhunk.snapenhance.common.BuildConfig
-import me.rhunk.snapenhance.common.Constants
-import me.rhunk.snapenhance.common.action.EnumAction
-import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
-import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
-import me.rhunk.snapenhance.core.ui.Snapenhance
-import me.rhunk.snapenhance.storage.getQuickTiles
-import me.rhunk.snapenhance.storage.setQuickTiles
-import me.rhunk.snapenhance.ui.manager.Routes
-import me.rhunk.snapenhance.ui.manager.data.Updater
-import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
-import java.text.DateFormat
-
-class HomeRoot : Routes.Route() {
- companion object {
- val cardMargin = 10.dp
- }
-
- private lateinit var activityLauncherHelper: ActivityLauncherHelper
-
- private fun launchActionIntent(action: EnumAction) {
- val intent = context.androidContext.packageManager.getLaunchIntentForPackage(
- Constants.SNAPCHAT_PACKAGE_NAME
- )
- intent?.putExtra(EnumAction.ACTION_PARAMETER, action.key)
- context.androidContext.startActivity(intent)
- }
-
- private val cards by lazy {
- EnumQuickActions.entries.map {
- (context.translation["actions.${it.key}.name"] to it.icon) to it.action
- }.associate {
- it.first to it.second
- }.toMutableMap().apply {
- EnumAction.entries.forEach { action ->
- this[context.translation["actions.${action.key}.name"] to action.icon] = {
- launchActionIntent(action)
- }
- }
- }
- }
-
- @Composable
- private fun InfoCard(
- content: @Composable ColumnScope.() -> Unit,
- ) {
- OutlinedCard(
- modifier = Modifier
- .padding(all = cardMargin)
- .fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant
- )
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(all = 15.dp)
- ) {
- content()
- }
- }
- }
-
- @Composable
- fun ExternalLinkIcon(
- modifier: Modifier = Modifier,
- size: Dp = 32.dp,
- imageVector: ImageVector,
- dataArray: IntArray
- ) {
- Icon(
- imageVector = imageVector,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier
- .size(size)
- .then(modifier)
- .clickable {
- context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply {
- data = Uri.parse(
- dataArray.reversed().map { (-it xor BuildConfig.APPLICATION_ID.hashCode()).toChar() }.joinToString("")
- )
- flags = Intent.FLAG_ACTIVITY_NEW_TASK
- })
- }
- )
- }
-
-
- override val init: () -> Unit = {
- activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
- }
-
- override val topBarActions: @Composable (RowScope.() -> Unit) = {
- IconButton(onClick = {
- routes.homeLogs.navigate()
- }) {
- Icon(Icons.Filled.BugReport, contentDescription = null)
- }
- IconButton(onClick = {
- routes.settings.navigate()
- }) {
- Icon(Icons.Filled.Settings, contentDescription = null)
- }
- }
-
-
- @OptIn(ExperimentalLayoutApi::class)
- override val content: @Composable (NavBackStackEntry) -> Unit = {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- ) {
- Icon(
- imageVector = Snapenhance, contentDescription = null,
- modifier = Modifier
- .fillMaxWidth()
- .padding(all = 8.dp)
- .align(Alignment.CenterHorizontally),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
-
- Text(
- text = translation.format(
- "version_title",
- "versionName" to BuildConfig.VERSION_NAME
- ),
- fontSize = 12.sp,
- fontFamily = remember {
- FontFamily(
- Font(R.font.avenir_next_medium, FontWeight.Medium)
- )
- },
- modifier = Modifier.align(Alignment.CenterHorizontally),
- )
-
- Row(
- horizontalArrangement = Arrangement.spacedBy(
- 15.dp, Alignment.CenterHorizontally
- ), modifier = Modifier
- .fillMaxWidth()
- .padding(all = 10.dp)
- ) {
- ExternalLinkIcon(
- imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
- // https://t.me/snapenhance
- dataArray = intArrayOf(
- 0xe4f8b47, 0xe4f8b41, 0xe4f8b4e, 0xe4f8b43, 0xe4f8b4c, 0xe4f8b4e, 0xe4f8b47,
- 0xe4f8b54, 0xe4f8b43, 0xe4f8b4e, 0xe4f8b51, 0xe4f8b0d, 0xe4f8b47, 0xe4f8b4f,
- 0xe4f8b0e, 0xe4f8b58, 0xe4f8b0d, 0xe4f8b0d, 0xe4f8b1a, 0xe4f8b51, 0xe4f8b54,
- 0xe4f8b58, 0xe4f8b58, 0xe4f8b4c
- )
- )
-
- ExternalLinkIcon(
- imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
- // https://github.com/rhunk/SnapEnhance
- dataArray = intArrayOf(
- 0xe4f8b47, 0xe4f8b41, 0xe4f8b4e, 0xe4f8b43, 0xe4f8b4c, 0xe4f8b4e, 0xe4f8b67,
- 0xe4f8b54, 0xe4f8b43, 0xe4f8b4e, 0xe4f8b71, 0xe4f8b0d, 0xe4f8b49, 0xe4f8b4e,
- 0xe4f8b57, 0xe4f8b4c, 0xe4f8b52, 0xe4f8b0d, 0xe4f8b4f, 0xe4f8b4d, 0xe4f8b41,
- 0xe4f8b0e, 0xe4f8b42, 0xe4f8b57, 0xe4f8b4c, 0xe4f8b58, 0xe4f8b4b, 0xe4f8b45,
- 0xe4f8b0d, 0xe4f8b0d, 0xe4f8b1a, 0xe4f8b51, 0xe4f8b54, 0xe4f8b58, 0xe4f8b58,
- 0xe4f8b4c
- )
- )
-
- ExternalLinkIcon(
- size = 36.dp,
- modifier = Modifier.offset(y = (-2).dp),
- imageVector = Icons.AutoMirrored.Default.Help,
- // https://github.com/rhunk/SnapEnhance/wiki
- dataArray = intArrayOf(
- 0xe4f8b4b, 0xe4f8b49, 0xe4f8b4b, 0xe4f8b55, 0xe4f8b0d, 0xe4f8b47, 0xe4f8b41,
- 0xe4f8b4e, 0xe4f8b43, 0xe4f8b4c, 0xe4f8b4e, 0xe4f8b67, 0xe4f8b54, 0xe4f8b43,
- 0xe4f8b4e, 0xe4f8b71, 0xe4f8b0d, 0xe4f8b49, 0xe4f8b4e, 0xe4f8b57, 0xe4f8b4c,
- 0xe4f8b52, 0xe4f8b0d, 0xe4f8b4f, 0xe4f8b4d, 0xe4f8b41, 0xe4f8b0e, 0xe4f8b42,
- 0xe4f8b57, 0xe4f8b4c, 0xe4f8b58, 0xe4f8b4b, 0xe4f8b45, 0xe4f8b0d, 0xe4f8b0d,
- 0xe4f8b1a, 0xe4f8b51, 0xe4f8b54, 0xe4f8b58, 0xe4f8b58, 0xe4f8b4c
- )
- )
- }
-
- val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
- context.database.getQuickTiles()
- }
-
- val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
- if (!BuildConfig.DEBUG) Updater.checkForLatestRelease() else null
- }
-
- if (latestUpdate != null) {
- Spacer(modifier = Modifier.height(10.dp))
- InfoCard {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column {
- Text(
- text = translation["update_title"],
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- )
- Text(
- fontSize = 12.sp, text = translation.format(
- "update_content",
- "version" to (latestUpdate?.versionName ?: "unknown")
- ), lineHeight = 20.sp
- )
- }
- Button(onClick = {
- context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply {
- data = Uri.parse(latestUpdate?.releaseUrl)
- })
- }, modifier = Modifier.height(40.dp)) {
- Text(text = translation["update_button"])
- }
- }
- }
- }
-
- if (BuildConfig.DEBUG) {
- Spacer(modifier = Modifier.height(10.dp))
- InfoCard {
- Text(
- text = translation["debug_build_summary_title"],
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- )
- val buildSummary = buildAnnotatedString {
- withStyle(
- style = SpanStyle(
- fontSize = 13.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- fontWeight = FontWeight.Light
- )
- ) {
- append(
- remember {
- translation.format(
- "debug_build_summary_content",
- "versionName" to BuildConfig.VERSION_NAME,
- "versionCode" to BuildConfig.VERSION_CODE.toString(),
- )
- }
- )
- append(" - ")
- }
- pushStringAnnotation(
- tag = "git_hash",
- annotation = BuildConfig.GIT_HASH
- )
- withStyle(
- style = SpanStyle(
- fontSize = 13.sp, fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary
- )
- ) {
- append(BuildConfig.GIT_HASH.substring(0, 7))
- }
- pop()
- }
- ClickableText(
- text = buildSummary,
- onClick = { offset ->
- buildSummary.getStringAnnotations(
- tag = "git_hash", start = offset, end = offset
- )
- .firstOrNull()?.let {
- context.activity?.startActivity(
- Intent(Intent.ACTION_VIEW).apply {
- data = Uri.parse(
- "https://github.com/rhunk/SnapEnhance/commit/${it.item}"
- )
- })
- }
- }
- )
- Text(
- fontSize = 12.sp,
- text = remember {
- translation.format(
- "debug_build_summary_date",
- "date" to DateFormat.getDateTimeInstance()
- .format(BuildConfig.BUILD_TIMESTAMP),
- "days" to ((System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP) / 86400000).toInt()
- .toString()
- )
- },
- lineHeight = 20.sp,
- fontWeight = FontWeight.Light
- )
- }
- }
-
- var showQuickActionsMenu by remember { mutableStateOf(false) }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 20.dp, end = 10.dp, top = 20.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- translation["quick_actions_title"], fontSize = 20.sp,
- modifier = Modifier.weight(1f)
- )
- Box {
- IconButton(
- onClick = { showQuickActionsMenu = !showQuickActionsMenu },
- ) {
- Icon(Icons.Default.MoreVert, contentDescription = null)
- }
- DropdownMenu(
- expanded = showQuickActionsMenu,
- onDismissRequest = { showQuickActionsMenu = false }
- ) {
- cards.forEach { (card, _) ->
- fun toggle(state: Boolean? = null) {
- if (state?.let { !it } ?: selectedTiles.contains(card.first)) {
- selectedTiles.remove(card.first)
- } else {
- selectedTiles.add(0, card.first)
- }
- context.coroutineScope.launch {
- context.database.setQuickTiles(selectedTiles)
- }
- }
-
- DropdownMenuItem(onClick = { toggle() }, text = {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(all = 5.dp)
- ) {
- Checkbox(
- checked = selectedTiles.contains(card.first),
- onCheckedChange = {
- toggle(it)
- }
- )
- Text(text = card.first)
- }
- })
- }
- }
- }
- }
-
- FlowRow(
- modifier = Modifier
- .padding(all = cardMargin)
- .fillMaxWidth(),
- maxItemsInEachRow = 3,
- horizontalArrangement = Arrangement.SpaceEvenly,
- ) {
- val tileHeight = LocalDensity.current.run {
- remember { (context.androidContext.resources.displayMetrics.widthPixels / 3).toDp() - cardMargin / 2 }
- }
-
- remember(selectedTiles.size, context.translation.loadedLocale) {
- selectedTiles.mapNotNull {
- cards.entries.find { entry -> entry.key.first == it }
- }
- }.forEach { (card, action) ->
- ElevatedCard(
- modifier = Modifier
- .height(tileHeight)
- .weight(1f)
- .clickable { action(routes) }
- .padding(all = 6.dp),
- ) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(all = 5.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.SpaceEvenly,
- ) {
- Icon(
- imageVector = card.second, contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.size(50.dp)
- )
- Text(
- text = card.first,
- lineHeight = 16.sp,
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
- }
- }
- }
- }
-}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt
@@ -0,0 +1,439 @@
+package me.rhunk.snapenhance.ui.manager.pages.home
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Help
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+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.text.withStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavBackStackEntry
+import kotlinx.coroutines.launch
+import me.rhunk.snapenhance.R
+import me.rhunk.snapenhance.action.EnumQuickActions
+import me.rhunk.snapenhance.common.BuildConfig
+import me.rhunk.snapenhance.common.Constants
+import me.rhunk.snapenhance.common.action.EnumAction
+import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
+import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
+import me.rhunk.snapenhance.core.ui.Snapenhance
+import me.rhunk.snapenhance.storage.getQuickTiles
+import me.rhunk.snapenhance.storage.setQuickTiles
+import me.rhunk.snapenhance.ui.manager.Routes
+import me.rhunk.snapenhance.ui.manager.data.Updater
+import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
+import java.text.DateFormat
+
+class HomeRootSection : Routes.Route() {
+ companion object {
+ val cardMargin = 10.dp
+ }
+
+ private lateinit var activityLauncherHelper: ActivityLauncherHelper
+
+ private fun launchActionIntent(action: EnumAction) {
+ val intent = context.androidContext.packageManager.getLaunchIntentForPackage(
+ Constants.SNAPCHAT_PACKAGE_NAME
+ )
+ intent?.putExtra(EnumAction.ACTION_PARAMETER, action.key)
+ context.androidContext.startActivity(intent)
+ }
+
+ private val cards by lazy {
+ EnumQuickActions.entries.map {
+ (context.translation["actions.${it.key}.name"] to it.icon) to it.action
+ }.associate {
+ it.first to it.second
+ }.toMutableMap().apply {
+ EnumAction.entries.forEach { action ->
+ this[context.translation["actions.${action.key}.name"] to action.icon] = {
+ launchActionIntent(action)
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun InfoCard(
+ content: @Composable ColumnScope.() -> Unit,
+ ) {
+ OutlinedCard(
+ modifier = Modifier
+ .padding(all = cardMargin)
+ .fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(all = 15.dp)
+ ) {
+ content()
+ }
+ }
+ }
+
+ @Composable
+ fun ExternalLinkIcon(
+ modifier: Modifier = Modifier,
+ size: Dp = 32.dp,
+ imageVector: ImageVector,
+ dataArray: IntArray
+ ) {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .size(size)
+ .then(modifier)
+ .clickable {
+ context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse(
+ dataArray.reversed().map { (-it xor BuildConfig.APPLICATION_ID.hashCode()).toChar() }.joinToString("")
+ )
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ })
+ }
+ )
+ }
+
+
+ override val init: () -> Unit = {
+ activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
+ }
+
+ override val topBarActions: @Composable (RowScope.() -> Unit) = {
+ IconButton(onClick = {
+ routes.homeLogs.navigate()
+ }) {
+ Icon(Icons.Filled.BugReport, contentDescription = null)
+ }
+ IconButton(onClick = {
+ routes.settings.navigate()
+ }) {
+ Icon(Icons.Filled.Settings, contentDescription = null)
+ }
+ }
+
+
+ @OptIn(ExperimentalLayoutApi::class)
+ override val content: @Composable (NavBackStackEntry) -> Unit = {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Icon(
+ imageVector = Snapenhance, contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(all = 8.dp)
+ .align(Alignment.CenterHorizontally),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Text(
+ text = translation.format(
+ "version_title",
+ "versionName" to BuildConfig.VERSION_NAME
+ ),
+ fontSize = 12.sp,
+ fontFamily = remember {
+ FontFamily(
+ Font(R.font.avenir_next_medium, FontWeight.Medium)
+ )
+ },
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(
+ 15.dp, Alignment.CenterHorizontally
+ ), modifier = Modifier
+ .fillMaxWidth()
+ .padding(all = 10.dp)
+ ) {
+ ExternalLinkIcon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
+ // https://t.me/snapenhance
+ dataArray = intArrayOf(
+ 0xe4f8b47, 0xe4f8b41, 0xe4f8b4e, 0xe4f8b43, 0xe4f8b4c, 0xe4f8b4e, 0xe4f8b47,
+ 0xe4f8b54, 0xe4f8b43, 0xe4f8b4e, 0xe4f8b51, 0xe4f8b0d, 0xe4f8b47, 0xe4f8b4f,
+ 0xe4f8b0e, 0xe4f8b58, 0xe4f8b0d, 0xe4f8b0d, 0xe4f8b1a, 0xe4f8b51, 0xe4f8b54,
+ 0xe4f8b58, 0xe4f8b58, 0xe4f8b4c
+ )
+ )
+
+ ExternalLinkIcon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
+ // https://github.com/rhunk/SnapEnhance
+ dataArray = intArrayOf(
+ 0xe4f8b47, 0xe4f8b41, 0xe4f8b4e, 0xe4f8b43, 0xe4f8b4c, 0xe4f8b4e, 0xe4f8b67,
+ 0xe4f8b54, 0xe4f8b43, 0xe4f8b4e, 0xe4f8b71, 0xe4f8b0d, 0xe4f8b49, 0xe4f8b4e,
+ 0xe4f8b57, 0xe4f8b4c, 0xe4f8b52, 0xe4f8b0d, 0xe4f8b4f, 0xe4f8b4d, 0xe4f8b41,
+ 0xe4f8b0e, 0xe4f8b42, 0xe4f8b57, 0xe4f8b4c, 0xe4f8b58, 0xe4f8b4b, 0xe4f8b45,
+ 0xe4f8b0d, 0xe4f8b0d, 0xe4f8b1a, 0xe4f8b51, 0xe4f8b54, 0xe4f8b58, 0xe4f8b58,
+ 0xe4f8b4c
+ )
+ )
+
+ ExternalLinkIcon(
+ size = 36.dp,
+ modifier = Modifier.offset(y = (-2).dp),
+ imageVector = Icons.AutoMirrored.Default.Help,
+ // https://github.com/rhunk/SnapEnhance/wiki
+ dataArray = intArrayOf(
+ 0xe4f8b4b, 0xe4f8b49, 0xe4f8b4b, 0xe4f8b55, 0xe4f8b0d, 0xe4f8b47, 0xe4f8b41,
+ 0xe4f8b4e, 0xe4f8b43, 0xe4f8b4c, 0xe4f8b4e, 0xe4f8b67, 0xe4f8b54, 0xe4f8b43,
+ 0xe4f8b4e, 0xe4f8b71, 0xe4f8b0d, 0xe4f8b49, 0xe4f8b4e, 0xe4f8b57, 0xe4f8b4c,
+ 0xe4f8b52, 0xe4f8b0d, 0xe4f8b4f, 0xe4f8b4d, 0xe4f8b41, 0xe4f8b0e, 0xe4f8b42,
+ 0xe4f8b57, 0xe4f8b4c, 0xe4f8b58, 0xe4f8b4b, 0xe4f8b45, 0xe4f8b0d, 0xe4f8b0d,
+ 0xe4f8b1a, 0xe4f8b51, 0xe4f8b54, 0xe4f8b58, 0xe4f8b58, 0xe4f8b4c
+ )
+ )
+ }
+
+ val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
+ context.database.getQuickTiles()
+ }
+
+ val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
+ if (!BuildConfig.DEBUG) Updater.checkForLatestRelease() else null
+ }
+
+ if (latestUpdate != null) {
+ Spacer(modifier = Modifier.height(10.dp))
+ InfoCard {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text(
+ text = translation["update_title"],
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ fontSize = 12.sp, text = translation.format(
+ "update_content",
+ "version" to (latestUpdate?.versionName ?: "unknown")
+ ), lineHeight = 20.sp
+ )
+ }
+ Button(onClick = {
+ context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse(latestUpdate?.releaseUrl)
+ })
+ }, modifier = Modifier.height(40.dp)) {
+ Text(text = translation["update_button"])
+ }
+ }
+ }
+ }
+
+ if (BuildConfig.DEBUG) {
+ Spacer(modifier = Modifier.height(10.dp))
+ InfoCard {
+ Text(
+ text = translation["debug_build_summary_title"],
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ val buildSummary = buildAnnotatedString {
+ withStyle(
+ style = SpanStyle(
+ fontSize = 13.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.Light
+ )
+ ) {
+ append(
+ remember {
+ translation.format(
+ "debug_build_summary_content",
+ "versionName" to BuildConfig.VERSION_NAME,
+ "versionCode" to BuildConfig.VERSION_CODE.toString(),
+ )
+ }
+ )
+ append(" - ")
+ }
+ pushStringAnnotation(
+ tag = "git_hash",
+ annotation = BuildConfig.GIT_HASH
+ )
+ withStyle(
+ style = SpanStyle(
+ fontSize = 13.sp, fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ append(BuildConfig.GIT_HASH.substring(0, 7))
+ }
+ pop()
+ }
+ ClickableText(
+ text = buildSummary,
+ onClick = { offset ->
+ buildSummary.getStringAnnotations(
+ tag = "git_hash", start = offset, end = offset
+ )
+ .firstOrNull()?.let {
+ context.activity?.startActivity(
+ Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse(
+ "https://github.com/rhunk/SnapEnhance/commit/${it.item}"
+ )
+ })
+ }
+ }
+ )
+ Text(
+ fontSize = 12.sp,
+ text = remember {
+ translation.format(
+ "debug_build_summary_date",
+ "date" to DateFormat.getDateTimeInstance()
+ .format(BuildConfig.BUILD_TIMESTAMP),
+ "days" to ((System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP) / 86400000).toInt()
+ .toString()
+ )
+ },
+ lineHeight = 20.sp,
+ fontWeight = FontWeight.Light
+ )
+ }
+ }
+
+ var showQuickActionsMenu by remember { mutableStateOf(false) }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 20.dp, end = 10.dp, top = 20.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ translation["quick_actions_title"], fontSize = 20.sp,
+ modifier = Modifier.weight(1f)
+ )
+ Box {
+ IconButton(
+ onClick = { showQuickActionsMenu = !showQuickActionsMenu },
+ ) {
+ Icon(Icons.Default.MoreVert, contentDescription = null)
+ }
+ DropdownMenu(
+ expanded = showQuickActionsMenu,
+ onDismissRequest = { showQuickActionsMenu = false }
+ ) {
+ cards.forEach { (card, _) ->
+ fun toggle(state: Boolean? = null) {
+ if (state?.let { !it } ?: selectedTiles.contains(card.first)) {
+ selectedTiles.remove(card.first)
+ } else {
+ selectedTiles.add(0, card.first)
+ }
+ context.coroutineScope.launch {
+ context.database.setQuickTiles(selectedTiles)
+ }
+ }
+
+ DropdownMenuItem(onClick = { toggle() }, text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(all = 5.dp)
+ ) {
+ Checkbox(
+ checked = selectedTiles.contains(card.first),
+ onCheckedChange = {
+ toggle(it)
+ }
+ )
+ Text(text = card.first)
+ }
+ })
+ }
+ }
+ }
+ }
+
+ FlowRow(
+ modifier = Modifier
+ .padding(all = cardMargin)
+ .fillMaxWidth(),
+ maxItemsInEachRow = 3,
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ val tileHeight = LocalDensity.current.run {
+ remember { (context.androidContext.resources.displayMetrics.widthPixels / 3).toDp() - cardMargin / 2 }
+ }
+
+ remember(selectedTiles.size, context.translation.loadedLocale) {
+ selectedTiles.mapNotNull {
+ cards.entries.find { entry -> entry.key.first == it }
+ }
+ }.forEach { (card, action) ->
+ ElevatedCard(
+ modifier = Modifier
+ .height(tileHeight)
+ .weight(1f)
+ .clickable { action(routes) }
+ .padding(all = 6.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(all = 5.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ Icon(
+ imageVector = card.second, contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(50.dp)
+ )
+ Text(
+ text = card.first,
+ lineHeight = 16.sp,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt
@@ -1,589 +0,0 @@
-package me.rhunk.snapenhance.ui.manager.pages.scripting
-
-import android.content.Intent
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.LibraryBooks
-import androidx.compose.material.icons.filled.*
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-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.graphics.vector.ImageVector
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-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 me.rhunk.snapenhance.common.scripting.type.ModuleInfo
-import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
-import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
-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.storage.isScriptEnabled
-import me.rhunk.snapenhance.storage.setScriptEnabled
-import me.rhunk.snapenhance.ui.manager.Routes
-import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
-import me.rhunk.snapenhance.ui.util.Dialog
-import me.rhunk.snapenhance.ui.util.chooseFolder
-import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
-import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
-import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
-
-class ScriptingRoot : Routes.Route() {
- private lateinit var activityLauncherHelper: ActivityLauncherHelper
- private val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
-
- override val init: () -> Unit = {
- activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
- }
-
- @Composable
- private fun ImportRemoteScript(
- dismiss: () -> Unit
- ) {
- Dialog(onDismissRequest = dismiss) {
- var url by remember { mutableStateOf("") }
- val focusRequester = remember { FocusRequester() }
- var isLoading by remember {
- mutableStateOf(false)
- }
- ElevatedCard(
- modifier = Modifier
- .fillMaxWidth(),
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = "Import Script from URL",
- fontSize = 22.sp,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(8.dp),
- )
- Text(
- text = "Warning: Imported scripts can be harmful to your device. Only import scripts from trusted sources.",
- fontSize = 14.sp,
- fontWeight = FontWeight.Light,
- fontStyle = FontStyle.Italic,
- modifier = Modifier.padding(8.dp),
- textAlign = TextAlign.Center,
- )
- TextField(
- value = url,
- onValueChange = {
- url = it
- },
- label = {
- Text(text = "Enter URL here:")
- },
- modifier = Modifier
- .fillMaxWidth()
- .focusRequester(focusRequester)
- .onGloballyPositioned {
- focusRequester.requestFocus()
- }
- )
- Spacer(modifier = Modifier.height(8.dp))
- Button(
- enabled = url.isNotBlank(),
- onClick = {
- isLoading = true
- context.coroutineScope.launch {
- runCatching {
- val moduleInfo = context.scriptManager.importFromUrl(url)
- context.shortToast("Script ${moduleInfo.name} imported!")
- reloadDispatcher.dispatch()
- withContext(Dispatchers.Main) {
- dismiss()
- }
- return@launch
- }.onFailure {
- context.log.error("Failed to import script", it)
- context.shortToast("Failed to import script. ${it.message}. Check logs for more details")
- }
- isLoading = false
- }
- },
- ) {
- if (isLoading) {
- CircularProgressIndicator(
- modifier = Modifier
- .size(30.dp),
- strokeWidth = 3.dp,
- color = MaterialTheme.colorScheme.onPrimary
- )
- } else {
- Text(text = "Import")
- }
- }
- }
- }
- }
- }
-
-
- @Composable
- private fun ModuleActions(
- script: ModuleInfo,
- canUpdate: Boolean,
- dismiss: () -> Unit
- ) {
- Dialog(
- onDismissRequest = dismiss,
- ) {
- ElevatedCard(
- modifier = Modifier
- .fillMaxWidth()
- .padding(2.dp),
- ) {
- val actions = remember {
- mutableMapOf<Pair<String, ImageVector>, suspend () -> Unit>().apply {
- if (canUpdate) {
- put("Update Module" to Icons.Default.Download) {
- dismiss()
- context.shortToast("Updating script ${script.name}...")
- runCatching {
- val modulePath = context.scriptManager.getModulePath(script.name) ?: throw Exception("Module not found")
- context.scriptManager.unloadScript(modulePath)
- val moduleInfo = context.scriptManager.importFromUrl(script.updateUrl!!, filepath = modulePath)
- context.shortToast("Updated ${script.name} to version ${moduleInfo.version}")
- context.database.setScriptEnabled(script.name, false)
- withContext(context.database.executor.asCoroutineDispatcher()) {
- reloadDispatcher.dispatch()
- }
- }.onFailure {
- context.log.error("Failed to update module", it)
- context.shortToast("Failed to update module. Check logs for more details")
- }
- }
- }
-
- put("Edit Module" to Icons.Default.Edit) {
- runCatching {
- val modulePath = context.scriptManager.getModulePath(script.name)!!
- context.androidContext.startActivity(
- Intent(Intent.ACTION_VIEW).apply {
- data = context.scriptManager.getScriptsFolder()!!
- .findFile(modulePath)!!.uri
- flags =
- Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
- }
- )
- dismiss()
- }.onFailure {
- context.log.error("Failed to open module file", it)
- context.shortToast("Failed to open module file. Check logs for more details")
- }
- }
- put("Clear Module Data" to Icons.Default.Save) {
- runCatching {
- context.scriptManager.getModuleDataFolder(script.name)
- .deleteRecursively()
- context.shortToast("Module data cleared!")
- dismiss()
- }.onFailure {
- context.log.error("Failed to clear module data", it)
- context.shortToast("Failed to clear module data. Check logs for more details")
- }
- }
- put("Delete Module" to Icons.Default.DeleteOutline) {
- context.scriptManager.apply {
- runCatching {
- val modulePath = getModulePath(script.name)!!
- unloadScript(modulePath)
- getScriptsFolder()?.findFile(modulePath)?.delete()
- reloadDispatcher.dispatch()
- context.shortToast("Deleted script ${script.name}!")
- dismiss()
- }.onFailure {
- context.log.error("Failed to delete module", it)
- context.shortToast("Failed to delete module. Check logs for more details")
- }
- }
- }
- }.toMap()
- }
-
- LazyColumn(
- modifier = Modifier.fillMaxWidth()
- ) {
- item {
- Text(
- text = "Actions",
- fontSize = 22.sp,
- fontWeight = FontWeight.Bold,
- modifier = Modifier
- .padding(16.dp)
- .fillMaxWidth(),
- textAlign = TextAlign.Center,
- )
- }
- items(actions.size) { index ->
- val action = actions.entries.elementAt(index)
- ListItem(
- modifier = Modifier
- .clickable {
- context.coroutineScope.launch {
- action.value()
- dismiss()
- }
- }
- .fillMaxWidth(),
- leadingContent = {
- Icon(
- imageVector = action.key.second,
- contentDescription = action.key.first
- )
- },
- headlineContent = {
- Text(text = action.key.first)
- },
- )
- }
- }
- }
- }
- }
-
- @Composable
- fun ModuleItem(script: ModuleInfo) {
- var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
- context.database.isScriptEnabled(script.name)
- }
- var openSettings by remember(script) { mutableStateOf(false) }
- var openActions by remember { mutableStateOf(false) }
-
- val dispatcher = rememberAsyncUpdateDispatcher()
- val reloadCallback = remember { suspend { dispatcher.dispatch() } }
- val latestUpdate by rememberAsyncMutableState(defaultValue = null, updateDispatcher = dispatcher, keys = arrayOf(script)) {
- context.scriptManager.checkForUpdate(script)
- }
-
- LaunchedEffect(Unit) {
- reloadDispatcher.addCallback(reloadCallback)
- }
-
- DisposableEffect(Unit) {
- onDispose {
- reloadDispatcher.removeCallback(reloadCallback)
- }
- }
-
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp),
- elevation = CardDefaults.cardElevation()
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable {
- if (!enabled) return@clickable
- openSettings = !openSettings
- }
- .padding(8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- if (enabled) {
- Icon(
- imageVector = if (openSettings) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
- contentDescription = null,
- modifier = Modifier
- .padding(end = 8.dp)
- .size(32.dp),
- )
- }
-
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 8.dp)
- ) {
- Text(text = script.displayName ?: script.name, fontSize = 20.sp)
- Text(text = script.description ?: "No description", fontSize = 14.sp)
- latestUpdate?.let {
- Text(text = "Update available: ${it.version}", fontSize = 14.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- }
- IconButton(onClick = {
- openActions = !openActions
- }) {
- Icon(imageVector = Icons.Default.Build, contentDescription = "Actions")
- }
- Switch(
- checked = enabled,
- onCheckedChange = { isChecked ->
- openSettings = false
- context.coroutineScope.launch(Dispatchers.IO) {
- runCatching {
- val modulePath = context.scriptManager.getModulePath(script.name)!!
- context.scriptManager.unloadScript(modulePath)
- if (isChecked) {
- context.scriptManager.loadScript(modulePath)
- context.scriptManager.runtime.getModuleByName(script.name)
- ?.callFunction("module.onSnapEnhanceLoad")
- context.shortToast("Loaded script ${script.name}")
- } else {
- context.shortToast("Unloaded script ${script.name}")
- }
-
- context.database.setScriptEnabled(script.name, isChecked)
- withContext(Dispatchers.Main) {
- enabled = isChecked
- }
- }.onFailure { throwable ->
- withContext(Dispatchers.Main) {
- enabled = !isChecked
- }
- ("Failed to ${if (isChecked) "enable" else "disable"} script. Check logs for more details").also {
- context.log.error(it, throwable)
- context.shortToast(it)
- }
- }
- }
- }
- )
- }
-
- if (openSettings) {
- ScriptSettings(script)
- }
- }
-
- if (openActions) {
- ModuleActions(
- script = script,
- canUpdate = latestUpdate != null,
- ) { openActions = false }
- }
- }
-
- override val floatingActionButton: @Composable () -> Unit = {
- var showImportDialog by remember {
- mutableStateOf(false)
- }
- if (showImportDialog) {
- ImportRemoteScript {
- showImportDialog = false
- }
- }
-
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- horizontalAlignment = Alignment.End,
- ) {
- ExtendedFloatingActionButton(
- onClick = {
- showImportDialog = true
- },
- icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
- text = {
- Text(text = "Import from URL")
- },
- )
- ExtendedFloatingActionButton(
- onClick = {
- context.scriptManager.getScriptsFolder()?.let {
- context.androidContext.startActivity(
- Intent(Intent.ACTION_VIEW).apply {
- data = it.uri
- flags = Intent.FLAG_ACTIVITY_NEW_TASK
- }
- )
- }
- },
- icon = {
- Icon(
- imageVector = Icons.Default.FolderOpen,
- contentDescription = "Folder"
- )
- },
- text = {
- Text(text = "Open Scripts Folder")
- },
- )
- }
- }
-
-
- @Composable
- fun ScriptSettings(script: ModuleInfo) {
- val settingsInterface = remember {
- val module =
- context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
- (module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS)
- }
-
- if (settingsInterface == null) {
- Text(
- text = "This module does not have any settings",
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.padding(8.dp)
- )
- } else {
- ScriptInterface(interfaceBuilder = settingsInterface)
- }
- }
-
- override val content: @Composable (NavBackStackEntry) -> Unit = {
- val scriptingFolder by rememberAsyncMutableState(
- defaultValue = null,
- updateDispatcher = reloadDispatcher
- ) {
- context.scriptManager.getScriptsFolder()
- }
- val scriptModules by rememberAsyncMutableState(
- defaultValue = emptyList(),
- updateDispatcher = reloadDispatcher
- ) {
- context.scriptManager.sync()
- context.scriptManager.getSyncedModules()
- }
-
- val coroutineScope = rememberCoroutineScope()
-
- var refreshing by remember {
- mutableStateOf(false)
- }
-
- LaunchedEffect(Unit) {
- refreshing = true
- withContext(Dispatchers.IO) {
- reloadDispatcher.dispatch()
- refreshing = false
- }
- }
-
- val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
- refreshing = true
- coroutineScope.launch(Dispatchers.IO) {
- reloadDispatcher.dispatch()
- refreshing = false
- }
- })
-
- Box(
- modifier = Modifier.fillMaxSize()
- ) {
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .pullRefresh(pullRefreshState),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- item {
- if (scriptingFolder == null && !refreshing) {
- Text(
- text = "No scripts folder selected",
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.padding(8.dp)
- )
- Spacer(modifier = Modifier.height(8.dp))
- Button(onClick = {
- activityLauncherHelper.chooseFolder {
- context.config.root.scripting.moduleFolder.set(it)
- context.config.writeConfig()
- coroutineScope.launch {
- reloadDispatcher.dispatch()
- }
- }
- }) {
- Text(text = "Select folder")
- }
- } else if (scriptModules.isEmpty()) {
- Text(
- text = "No scripts found",
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.padding(8.dp)
- )
- }
- }
- items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
- ModuleItem(scriptModules[index])
- }
- item {
- Spacer(modifier = Modifier.height(200.dp))
- }
- }
-
- PullRefreshIndicator(
- refreshing = refreshing,
- state = pullRefreshState,
- modifier = Modifier.align(Alignment.TopCenter)
- )
- }
-
- var scriptingWarning by remember {
- mutableStateOf(context.sharedPreferences.run {
- getBoolean("scripting_warning", true).also {
- edit().putBoolean("scripting_warning", false).apply()
- }
- })
- }
-
- if (scriptingWarning) {
- var timeout by remember {
- mutableIntStateOf(10)
- }
-
- LaunchedEffect(Unit) {
- while (timeout > 0) {
- delay(1000)
- timeout--
- }
- }
-
- AlertDialog(onDismissRequest = {
- if (timeout == 0) {
- scriptingWarning = false
- }
- }, title = {
- Text(text = context.translation["manager.dialogs.scripting_warning.title"])
- }, text = {
- Text(text = context.translation["manager.dialogs.scripting_warning.content"])
- }, confirmButton = {
- TextButton(
- onClick = {
- scriptingWarning = false
- },
- enabled = timeout == 0
- ) {
- Text(text = "OK " + if (timeout > 0) "($timeout)" else "")
- }
- })
- }
- }
-
- override val topBarActions: @Composable() (RowScope.() -> Unit) = {
- IconButton(onClick = {
- context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
- data = "https://github.com/SnapEnhance/docs".toUri()
- flags = Intent.FLAG_ACTIVITY_NEW_TASK
- })
- }) {
- Icon(
- imageVector = Icons.AutoMirrored.Default.LibraryBooks,
- contentDescription = "Documentation"
- )
- }
- }
-}-
\ 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
@@ -0,0 +1,589 @@
+package me.rhunk.snapenhance.ui.manager.pages.scripting
+
+import android.content.Intent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.LibraryBooks
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+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.graphics.vector.ImageVector
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+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 me.rhunk.snapenhance.common.scripting.type.ModuleInfo
+import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
+import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
+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.storage.isScriptEnabled
+import me.rhunk.snapenhance.storage.setScriptEnabled
+import me.rhunk.snapenhance.ui.manager.Routes
+import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
+import me.rhunk.snapenhance.ui.util.Dialog
+import me.rhunk.snapenhance.ui.util.chooseFolder
+import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
+import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
+import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
+
+class ScriptingRootSection : Routes.Route() {
+ private lateinit var activityLauncherHelper: ActivityLauncherHelper
+ private val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
+
+ override val init: () -> Unit = {
+ activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
+ }
+
+ @Composable
+ private fun ImportRemoteScript(
+ dismiss: () -> Unit
+ ) {
+ Dialog(onDismissRequest = dismiss) {
+ var url by remember { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
+ var isLoading by remember {
+ mutableStateOf(false)
+ }
+ ElevatedCard(
+ modifier = Modifier
+ .fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Import Script from URL",
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(8.dp),
+ )
+ Text(
+ text = "Warning: Imported scripts can be harmful to your device. Only import scripts from trusted sources.",
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ fontStyle = FontStyle.Italic,
+ modifier = Modifier.padding(8.dp),
+ textAlign = TextAlign.Center,
+ )
+ TextField(
+ value = url,
+ onValueChange = {
+ url = it
+ },
+ label = {
+ Text(text = "Enter URL here:")
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .onGloballyPositioned {
+ focusRequester.requestFocus()
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ enabled = url.isNotBlank(),
+ onClick = {
+ isLoading = true
+ context.coroutineScope.launch {
+ runCatching {
+ val moduleInfo = context.scriptManager.importFromUrl(url)
+ context.shortToast("Script ${moduleInfo.name} imported!")
+ reloadDispatcher.dispatch()
+ withContext(Dispatchers.Main) {
+ dismiss()
+ }
+ return@launch
+ }.onFailure {
+ context.log.error("Failed to import script", it)
+ context.shortToast("Failed to import script. ${it.message}. Check logs for more details")
+ }
+ isLoading = false
+ }
+ },
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(30.dp),
+ strokeWidth = 3.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text(text = "Import")
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+ @Composable
+ private fun ModuleActions(
+ script: ModuleInfo,
+ canUpdate: Boolean,
+ dismiss: () -> Unit
+ ) {
+ Dialog(
+ onDismissRequest = dismiss,
+ ) {
+ ElevatedCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(2.dp),
+ ) {
+ val actions = remember {
+ mutableMapOf<Pair<String, ImageVector>, suspend () -> Unit>().apply {
+ if (canUpdate) {
+ put("Update Module" to Icons.Default.Download) {
+ dismiss()
+ context.shortToast("Updating script ${script.name}...")
+ runCatching {
+ val modulePath = context.scriptManager.getModulePath(script.name) ?: throw Exception("Module not found")
+ context.scriptManager.unloadScript(modulePath)
+ val moduleInfo = context.scriptManager.importFromUrl(script.updateUrl!!, filepath = modulePath)
+ context.shortToast("Updated ${script.name} to version ${moduleInfo.version}")
+ context.database.setScriptEnabled(script.name, false)
+ withContext(context.database.executor.asCoroutineDispatcher()) {
+ reloadDispatcher.dispatch()
+ }
+ }.onFailure {
+ context.log.error("Failed to update module", it)
+ context.shortToast("Failed to update module. Check logs for more details")
+ }
+ }
+ }
+
+ put("Edit Module" to Icons.Default.Edit) {
+ runCatching {
+ val modulePath = context.scriptManager.getModulePath(script.name)!!
+ context.androidContext.startActivity(
+ Intent(Intent.ACTION_VIEW).apply {
+ data = context.scriptManager.getScriptsFolder()!!
+ .findFile(modulePath)!!.uri
+ flags =
+ Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ }
+ )
+ dismiss()
+ }.onFailure {
+ context.log.error("Failed to open module file", it)
+ context.shortToast("Failed to open module file. Check logs for more details")
+ }
+ }
+ put("Clear Module Data" to Icons.Default.Save) {
+ runCatching {
+ context.scriptManager.getModuleDataFolder(script.name)
+ .deleteRecursively()
+ context.shortToast("Module data cleared!")
+ dismiss()
+ }.onFailure {
+ context.log.error("Failed to clear module data", it)
+ context.shortToast("Failed to clear module data. Check logs for more details")
+ }
+ }
+ put("Delete Module" to Icons.Default.DeleteOutline) {
+ context.scriptManager.apply {
+ runCatching {
+ val modulePath = getModulePath(script.name)!!
+ unloadScript(modulePath)
+ getScriptsFolder()?.findFile(modulePath)?.delete()
+ reloadDispatcher.dispatch()
+ context.shortToast("Deleted script ${script.name}!")
+ dismiss()
+ }.onFailure {
+ context.log.error("Failed to delete module", it)
+ context.shortToast("Failed to delete module. Check logs for more details")
+ }
+ }
+ }
+ }.toMap()
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ item {
+ Text(
+ text = "Actions",
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ )
+ }
+ items(actions.size) { index ->
+ val action = actions.entries.elementAt(index)
+ ListItem(
+ modifier = Modifier
+ .clickable {
+ context.coroutineScope.launch {
+ action.value()
+ dismiss()
+ }
+ }
+ .fillMaxWidth(),
+ leadingContent = {
+ Icon(
+ imageVector = action.key.second,
+ contentDescription = action.key.first
+ )
+ },
+ headlineContent = {
+ Text(text = action.key.first)
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun ModuleItem(script: ModuleInfo) {
+ var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
+ context.database.isScriptEnabled(script.name)
+ }
+ var openSettings by remember(script) { mutableStateOf(false) }
+ var openActions by remember { mutableStateOf(false) }
+
+ val dispatcher = rememberAsyncUpdateDispatcher()
+ val reloadCallback = remember { suspend { dispatcher.dispatch() } }
+ val latestUpdate by rememberAsyncMutableState(defaultValue = null, updateDispatcher = dispatcher, keys = arrayOf(script)) {
+ context.scriptManager.checkForUpdate(script)
+ }
+
+ LaunchedEffect(Unit) {
+ reloadDispatcher.addCallback(reloadCallback)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ reloadDispatcher.removeCallback(reloadCallback)
+ }
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ elevation = CardDefaults.cardElevation()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ if (!enabled) return@clickable
+ openSettings = !openSettings
+ }
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (enabled) {
+ Icon(
+ imageVector = if (openSettings) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(end = 8.dp)
+ .size(32.dp),
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 8.dp)
+ ) {
+ Text(text = script.displayName ?: script.name, fontSize = 20.sp)
+ Text(text = script.description ?: "No description", fontSize = 14.sp)
+ latestUpdate?.let {
+ Text(text = "Update available: ${it.version}", fontSize = 14.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ IconButton(onClick = {
+ openActions = !openActions
+ }) {
+ Icon(imageVector = Icons.Default.Build, contentDescription = "Actions")
+ }
+ Switch(
+ checked = enabled,
+ onCheckedChange = { isChecked ->
+ openSettings = false
+ context.coroutineScope.launch(Dispatchers.IO) {
+ runCatching {
+ val modulePath = context.scriptManager.getModulePath(script.name)!!
+ context.scriptManager.unloadScript(modulePath)
+ if (isChecked) {
+ context.scriptManager.loadScript(modulePath)
+ context.scriptManager.runtime.getModuleByName(script.name)
+ ?.callFunction("module.onSnapEnhanceLoad")
+ context.shortToast("Loaded script ${script.name}")
+ } else {
+ context.shortToast("Unloaded script ${script.name}")
+ }
+
+ context.database.setScriptEnabled(script.name, isChecked)
+ withContext(Dispatchers.Main) {
+ enabled = isChecked
+ }
+ }.onFailure { throwable ->
+ withContext(Dispatchers.Main) {
+ enabled = !isChecked
+ }
+ ("Failed to ${if (isChecked) "enable" else "disable"} script. Check logs for more details").also {
+ context.log.error(it, throwable)
+ context.shortToast(it)
+ }
+ }
+ }
+ }
+ )
+ }
+
+ if (openSettings) {
+ ScriptSettings(script)
+ }
+ }
+
+ if (openActions) {
+ ModuleActions(
+ script = script,
+ canUpdate = latestUpdate != null,
+ ) { openActions = false }
+ }
+ }
+
+ override val floatingActionButton: @Composable () -> Unit = {
+ var showImportDialog by remember {
+ mutableStateOf(false)
+ }
+ if (showImportDialog) {
+ ImportRemoteScript {
+ showImportDialog = false
+ }
+ }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalAlignment = Alignment.End,
+ ) {
+ ExtendedFloatingActionButton(
+ onClick = {
+ showImportDialog = true
+ },
+ icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
+ text = {
+ Text(text = "Import from URL")
+ },
+ )
+ ExtendedFloatingActionButton(
+ onClick = {
+ context.scriptManager.getScriptsFolder()?.let {
+ context.androidContext.startActivity(
+ Intent(Intent.ACTION_VIEW).apply {
+ data = it.uri
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ )
+ }
+ },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.FolderOpen,
+ contentDescription = "Folder"
+ )
+ },
+ text = {
+ Text(text = "Open Scripts Folder")
+ },
+ )
+ }
+ }
+
+
+ @Composable
+ fun ScriptSettings(script: ModuleInfo) {
+ val settingsInterface = remember {
+ val module =
+ context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
+ (module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS)
+ }
+
+ if (settingsInterface == null) {
+ Text(
+ text = "This module does not have any settings",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(8.dp)
+ )
+ } else {
+ ScriptInterface(interfaceBuilder = settingsInterface)
+ }
+ }
+
+ override val content: @Composable (NavBackStackEntry) -> Unit = {
+ val scriptingFolder by rememberAsyncMutableState(
+ defaultValue = null,
+ updateDispatcher = reloadDispatcher
+ ) {
+ context.scriptManager.getScriptsFolder()
+ }
+ val scriptModules by rememberAsyncMutableState(
+ defaultValue = emptyList(),
+ updateDispatcher = reloadDispatcher
+ ) {
+ context.scriptManager.sync()
+ context.scriptManager.getSyncedModules()
+ }
+
+ val coroutineScope = rememberCoroutineScope()
+
+ var refreshing by remember {
+ mutableStateOf(false)
+ }
+
+ LaunchedEffect(Unit) {
+ refreshing = true
+ withContext(Dispatchers.IO) {
+ reloadDispatcher.dispatch()
+ refreshing = false
+ }
+ }
+
+ val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
+ refreshing = true
+ coroutineScope.launch(Dispatchers.IO) {
+ reloadDispatcher.dispatch()
+ refreshing = false
+ }
+ })
+
+ Box(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .pullRefresh(pullRefreshState),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ item {
+ if (scriptingFolder == null && !refreshing) {
+ Text(
+ text = "No scripts folder selected",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(8.dp)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = {
+ activityLauncherHelper.chooseFolder {
+ context.config.root.scripting.moduleFolder.set(it)
+ context.config.writeConfig()
+ coroutineScope.launch {
+ reloadDispatcher.dispatch()
+ }
+ }
+ }) {
+ Text(text = "Select folder")
+ }
+ } else if (scriptModules.isEmpty()) {
+ Text(
+ text = "No scripts found",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(8.dp)
+ )
+ }
+ }
+ items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
+ ModuleItem(scriptModules[index])
+ }
+ item {
+ Spacer(modifier = Modifier.height(200.dp))
+ }
+ }
+
+ PullRefreshIndicator(
+ refreshing = refreshing,
+ state = pullRefreshState,
+ modifier = Modifier.align(Alignment.TopCenter)
+ )
+ }
+
+ var scriptingWarning by remember {
+ mutableStateOf(context.sharedPreferences.run {
+ getBoolean("scripting_warning", true).also {
+ edit().putBoolean("scripting_warning", false).apply()
+ }
+ })
+ }
+
+ if (scriptingWarning) {
+ var timeout by remember {
+ mutableIntStateOf(10)
+ }
+
+ LaunchedEffect(Unit) {
+ while (timeout > 0) {
+ delay(1000)
+ timeout--
+ }
+ }
+
+ AlertDialog(onDismissRequest = {
+ if (timeout == 0) {
+ scriptingWarning = false
+ }
+ }, title = {
+ Text(text = context.translation["manager.dialogs.scripting_warning.title"])
+ }, text = {
+ Text(text = context.translation["manager.dialogs.scripting_warning.content"])
+ }, confirmButton = {
+ TextButton(
+ onClick = {
+ scriptingWarning = false
+ },
+ enabled = timeout == 0
+ ) {
+ Text(text = "OK " + if (timeout > 0) "($timeout)" else "")
+ }
+ })
+ }
+ }
+
+ override val topBarActions: @Composable() (RowScope.() -> Unit) = {
+ IconButton(onClick = {
+ context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
+ data = "https://github.com/SnapEnhance/docs".toUri()
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ })
+ }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.LibraryBooks,
+ contentDescription = "Documentation"
+ )
+ }
+ }
+}+
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt
@@ -1,289 +0,0 @@
-package me.rhunk.snapenhance.ui.manager.pages.social
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.RemoveRedEye
-import androidx.compose.material.icons.rounded.Add
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.res.vectorResource
-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.navigation.NavBackStackEntry
-import kotlinx.coroutines.launch
-import me.rhunk.snapenhance.R
-import me.rhunk.snapenhance.common.data.MessagingFriendInfo
-import me.rhunk.snapenhance.common.data.MessagingGroupInfo
-import me.rhunk.snapenhance.common.data.SocialScope
-import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
-import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
-import me.rhunk.snapenhance.storage.*
-import me.rhunk.snapenhance.ui.manager.Routes
-import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
-import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
-
-class SocialRoot : Routes.Route() {
- private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
- private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
-
- private fun updateScopeLists() {
- context.coroutineScope.launch {
- friendList = context.database.getFriends(descOrder = true)
- groupList = context.database.getGroups()
- }
- }
-
- private val addFriendDialog by lazy {
- AddFriendDialog(context, AddFriendDialog.Actions(
- onFriendState = { friend, state ->
- if (state) {
- context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId)
- } else {
- context.database.deleteFriend(friend.userId)
- }
- },
- onGroupState = { group, state ->
- if (state) {
- context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId)
- } else {
- context.database.deleteGroup(group.conversationId)
- }
- },
- getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
- getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
- ))
- }
-
- @Composable
- private fun ScopeList(scope: SocialScope) {
- val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
-
- LazyColumn(
- modifier = Modifier
- .padding(2.dp)
- .fillMaxWidth()
- .fillMaxHeight(),
- contentPadding = PaddingValues(bottom = 110.dp),
- ) {
- //check if scope list is empty
- val listSize = when (scope) {
- SocialScope.GROUP -> groupList.size
- SocialScope.FRIEND -> friendList.size
- }
-
- if (listSize == 0) {
- item {
- Text(
- text = translation["empty_hint"], modifier = Modifier
- .fillMaxWidth()
- .padding(10.dp), textAlign = TextAlign.Center
- )
- }
- }
-
- items(listSize) { index ->
- val id = when (scope) {
- SocialScope.GROUP -> groupList[index].conversationId
- SocialScope.FRIEND -> friendList[index].userId
- }
-
- ElevatedCard(
- modifier = Modifier
- .padding(10.dp)
- .fillMaxWidth()
- .height(80.dp)
- .clickable {
- routes.manageScope.navigate {
- put("id", id)
- put("scope", scope.key)
- }
- },
- ) {
- Row(
- modifier = Modifier
- .padding(10.dp)
- .fillMaxSize(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- when (scope) {
- SocialScope.GROUP -> {
- val group = groupList[index]
- Column(
- modifier = Modifier
- .padding(7.dp)
- .fillMaxWidth()
- .weight(1f)
- ) {
- Text(
- text = group.name,
- maxLines = 1,
- fontWeight = FontWeight.Bold
- )
- }
- }
-
- SocialScope.FRIEND -> {
- val friend = friendList[index]
- val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) {
- context.database.getFriendStreaks(friend.userId)
- }
-
- BitmojiImage(
- context = context,
- url = BitmojiSelfie.getBitmojiSelfie(
- friend.selfieId,
- friend.bitmojiId,
- BitmojiSelfie.BitmojiSelfieType.THREE_D
- )
- )
- Column(
- modifier = Modifier
- .padding(7.dp)
- .fillMaxWidth()
- .weight(1f)
- ) {
- Text(
- text = friend.displayName ?: friend.mutableUsername,
- maxLines = 1,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = friend.mutableUsername,
- maxLines = 1,
- fontSize = 12.sp,
- fontWeight = FontWeight.Light
- )
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- streaks?.takeIf { it.notify }?.let { streaks ->
- Icon(
- imageVector = ImageVector.vectorResource(id = R.drawable.streak_icon),
- contentDescription = null,
- modifier = Modifier.height(40.dp),
- tint = if (streaks.isAboutToExpire(remainingHours))
- MaterialTheme.colorScheme.error
- else MaterialTheme.colorScheme.primary
- )
- Text(
- text = translation.format(
- "streaks_expiration_short",
- "hours" to (((streaks.expirationTimestamp - System.currentTimeMillis()) / 3600000).toInt().takeIf { it > 0 } ?: 0)
- .toString()
- ),
- maxLines = 1,
- fontWeight = FontWeight.Bold
- )
- }
- }
- }
- }
-
- FilledIconButton(onClick = {
- routes.messagingPreview.navigate {
- put("id", id)
- put("scope", scope.key)
- }
- }) {
- Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null)
- }
- }
- }
- }
- }
- }
-
- @OptIn(ExperimentalFoundationApi::class)
- override val content: @Composable (NavBackStackEntry) -> Unit = {
- val titles = remember {
- listOf(translation["friends_tab"], translation["groups_tab"])
- }
- val coroutineScope = rememberCoroutineScope()
- val pagerState = rememberPagerState { titles.size }
- var showAddFriendDialog by remember { mutableStateOf(false) }
-
- if (showAddFriendDialog) {
- addFriendDialog.Content {
- showAddFriendDialog = false
- }
- DisposableEffect(Unit) {
- onDispose {
- updateScopeLists()
- }
- }
- }
-
- LaunchedEffect(Unit) {
- updateScopeLists()
- }
-
- Scaffold(
- floatingActionButton = {
- FloatingActionButton(
- onClick = {
- showAddFriendDialog = true
- },
- modifier = Modifier.padding(10.dp),
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary,
- shape = RoundedCornerShape(16.dp),
- ) {
- Icon(
- imageVector = Icons.Rounded.Add,
- contentDescription = null
- )
- }
- }
- ) { paddingValues ->
- Column(modifier = Modifier.padding(paddingValues)) {
- TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions ->
- TabRowDefaults.SecondaryIndicator(
- Modifier.pagerTabIndicatorOffset(
- pagerState = pagerState,
- tabPositions = tabPositions
- )
- )
- }) {
- titles.forEachIndexed { index, title ->
- Tab(
- selected = pagerState.currentPage == index,
- onClick = {
- coroutineScope.launch {
- pagerState.animateScrollToPage(index)
- }
- },
- text = {
- Text(
- text = title,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
- )
- }
- }
-
- HorizontalPager(
- modifier = Modifier.padding(paddingValues),
- state = pagerState
- ) { page ->
- when (page) {
- 0 -> ScopeList(SocialScope.FRIEND)
- 1 -> ScopeList(SocialScope.GROUP)
- }
- }
- }
- }
- }
-}-
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRootSection.kt
@@ -0,0 +1,289 @@
+package me.rhunk.snapenhance.ui.manager.pages.social
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.RemoveRedEye
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.vectorResource
+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.navigation.NavBackStackEntry
+import kotlinx.coroutines.launch
+import me.rhunk.snapenhance.R
+import me.rhunk.snapenhance.common.data.MessagingFriendInfo
+import me.rhunk.snapenhance.common.data.MessagingGroupInfo
+import me.rhunk.snapenhance.common.data.SocialScope
+import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
+import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
+import me.rhunk.snapenhance.storage.*
+import me.rhunk.snapenhance.ui.manager.Routes
+import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
+import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
+
+class SocialRootSection : Routes.Route() {
+ private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
+ private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
+
+ private fun updateScopeLists() {
+ context.coroutineScope.launch {
+ friendList = context.database.getFriends(descOrder = true)
+ groupList = context.database.getGroups()
+ }
+ }
+
+ private val addFriendDialog by lazy {
+ AddFriendDialog(context, AddFriendDialog.Actions(
+ onFriendState = { friend, state ->
+ if (state) {
+ context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId)
+ } else {
+ context.database.deleteFriend(friend.userId)
+ }
+ },
+ onGroupState = { group, state ->
+ if (state) {
+ context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId)
+ } else {
+ context.database.deleteGroup(group.conversationId)
+ }
+ },
+ getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
+ getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
+ ))
+ }
+
+ @Composable
+ private fun ScopeList(scope: SocialScope) {
+ val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
+
+ LazyColumn(
+ modifier = Modifier
+ .padding(2.dp)
+ .fillMaxWidth()
+ .fillMaxHeight(),
+ contentPadding = PaddingValues(bottom = 110.dp),
+ ) {
+ //check if scope list is empty
+ val listSize = when (scope) {
+ SocialScope.GROUP -> groupList.size
+ SocialScope.FRIEND -> friendList.size
+ }
+
+ if (listSize == 0) {
+ item {
+ Text(
+ text = translation["empty_hint"], modifier = Modifier
+ .fillMaxWidth()
+ .padding(10.dp), textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ items(listSize) { index ->
+ val id = when (scope) {
+ SocialScope.GROUP -> groupList[index].conversationId
+ SocialScope.FRIEND -> friendList[index].userId
+ }
+
+ ElevatedCard(
+ modifier = Modifier
+ .padding(10.dp)
+ .fillMaxWidth()
+ .height(80.dp)
+ .clickable {
+ routes.manageScope.navigate {
+ put("id", id)
+ put("scope", scope.key)
+ }
+ },
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(10.dp)
+ .fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ when (scope) {
+ SocialScope.GROUP -> {
+ val group = groupList[index]
+ Column(
+ modifier = Modifier
+ .padding(7.dp)
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ Text(
+ text = group.name,
+ maxLines = 1,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+
+ SocialScope.FRIEND -> {
+ val friend = friendList[index]
+ val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) {
+ context.database.getFriendStreaks(friend.userId)
+ }
+
+ BitmojiImage(
+ context = context,
+ url = BitmojiSelfie.getBitmojiSelfie(
+ friend.selfieId,
+ friend.bitmojiId,
+ BitmojiSelfie.BitmojiSelfieType.THREE_D
+ )
+ )
+ Column(
+ modifier = Modifier
+ .padding(7.dp)
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ Text(
+ text = friend.displayName ?: friend.mutableUsername,
+ maxLines = 1,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = friend.mutableUsername,
+ maxLines = 1,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Light
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ streaks?.takeIf { it.notify }?.let { streaks ->
+ Icon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.streak_icon),
+ contentDescription = null,
+ modifier = Modifier.height(40.dp),
+ tint = if (streaks.isAboutToExpire(remainingHours))
+ MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = translation.format(
+ "streaks_expiration_short",
+ "hours" to (((streaks.expirationTimestamp - System.currentTimeMillis()) / 3600000).toInt().takeIf { it > 0 } ?: 0)
+ .toString()
+ ),
+ maxLines = 1,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+ }
+
+ FilledIconButton(onClick = {
+ routes.messagingPreview.navigate {
+ put("id", id)
+ put("scope", scope.key)
+ }
+ }) {
+ Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @OptIn(ExperimentalFoundationApi::class)
+ override val content: @Composable (NavBackStackEntry) -> Unit = {
+ val titles = remember {
+ listOf(translation["friends_tab"], translation["groups_tab"])
+ }
+ val coroutineScope = rememberCoroutineScope()
+ val pagerState = rememberPagerState { titles.size }
+ var showAddFriendDialog by remember { mutableStateOf(false) }
+
+ if (showAddFriendDialog) {
+ addFriendDialog.Content {
+ showAddFriendDialog = false
+ }
+ DisposableEffect(Unit) {
+ onDispose {
+ updateScopeLists()
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ updateScopeLists()
+ }
+
+ Scaffold(
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = {
+ showAddFriendDialog = true
+ },
+ modifier = Modifier.padding(10.dp),
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ shape = RoundedCornerShape(16.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Add,
+ contentDescription = null
+ )
+ }
+ }
+ ) { paddingValues ->
+ Column(modifier = Modifier.padding(paddingValues)) {
+ TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions ->
+ TabRowDefaults.SecondaryIndicator(
+ Modifier.pagerTabIndicatorOffset(
+ pagerState = pagerState,
+ tabPositions = tabPositions
+ )
+ )
+ }) {
+ titles.forEachIndexed { index, title ->
+ Tab(
+ selected = pagerState.currentPage == index,
+ onClick = {
+ coroutineScope.launch {
+ pagerState.animateScrollToPage(index)
+ }
+ },
+ text = {
+ Text(
+ text = title,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ )
+ }
+ }
+
+ HorizontalPager(
+ modifier = Modifier.padding(paddingValues),
+ state = pagerState
+ ) { page ->
+ when (page) {
+ 0 -> ScopeList(SocialScope.FRIEND)
+ 1 -> ScopeList(SocialScope.GROUP)
+ }
+ }
+ }
+ }
+ }
+}+
\ No newline at end of file