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:
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt | 26+++++++++++---------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt | 475-------------------------------------------------------------------------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt | 475+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt | 708-------------------------------------------------------------------------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt | 708+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt | 439-------------------------------------------------------------------------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt | 439+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt | 590-------------------------------------------------------------------------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt | 590+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt | 290-------------------------------------------------------------------------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRootSection.kt | 290+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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