UI Fixes for Aphelion and Optimizations

This commit is contained in:
DarkKnight2122
2026-03-15 06:20:29 +05:30
parent d1fc97cee8
commit 8c720992de
21 changed files with 1743 additions and 1028 deletions

View File

@@ -12,11 +12,9 @@ import android.provider.MediaStore
import android.widget.Toast
import androidx.documentfile.provider.DocumentFile
import com.google.gson.GsonBuilder
import kotlinx.coroutines.Job
import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.common.Constants
@@ -62,6 +60,10 @@ class DownloadProcessor (
private val remoteSideContext: RemoteSideContext,
private val callback: DownloadCallback
) {
companion object {
private val downloadSemaphore = Semaphore(3)
}
private data class GallerySaveResult(
val uri: Uri,
val alreadyDownloaded: Boolean = false
@@ -441,8 +443,7 @@ class DownloadProcessor (
return File.createTempFile("media", ".tmp")
}
private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking {
val jobs = mutableListOf<Job>()
private suspend fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest): Map<InputMedia, File> {
val downloadedMedias = mutableMapOf<InputMedia, File>()
var totalSize = 1L
val inputMediaDownloadedBytes = mutableMapOf<InputMedia, Long>()
@@ -455,71 +456,72 @@ class DownloadProcessor (
)
}
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
coroutineScope {
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}
}
}
}
}.also { jobs.add(it) }
}
}
jobs.joinAll()
downloadedMedias
return downloadedMedias
}
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, File>, downloadRequest: DownloadRequest) {
downloadRequest.inputMedias.first().let { inputMedia ->
val mediaType = inputMedia.type
val media = downloadedMedias[inputMedia]!!
@@ -530,24 +532,24 @@ class DownloadProcessor (
val outputFile = File.createTempFile("voice_note", ".$format")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.CONVERSION,
inputs = listOf(media.file.absolutePath),
inputs = listOf(media.absolutePath),
output = outputFile
))
media.file.delete()
media.delete()
saveMediaToGallery(pendingTask, outputFile, metadata)
outputFile.delete()
return
}
}
saveMediaToGallery(pendingTask, media.file, metadata)
media.file.delete()
saveMediaToGallery(pendingTask, media, metadata)
media.delete()
return
}
assert(mediaType == DownloadMediaType.REMOTE_MEDIA)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media)
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL")
for (i in 0 until baseUrlNodeList.length) {
val baseUrlNode = baseUrlNodeList.item(i)
@@ -557,7 +559,7 @@ class DownloadProcessor (
val dashOptions = downloadRequest.dashOptions!!
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
val dashPlaylistFile = renameFromFileType(media, FileType.MPD)
dashPlaylistFile.outputStream().use {
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it))
}
@@ -582,7 +584,7 @@ class DownloadProcessor (
dashPlaylistFile.delete()
outputFile.delete()
media.file.delete()
media.delete()
}
}
@@ -602,7 +604,7 @@ class DownloadProcessor (
// check if the media file has been deleted
if (task.type == TaskType.DOWNLOAD) {
val outputFile = runCatching {
DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(task.extra))
DocumentFile.fromSingleUri(remoteSideContext.androidContext, Uri.parse(task.extra))
}.getOrNull()
if (outputFile != null && !outputFile.exists()) {
@@ -617,113 +619,115 @@ class DownloadProcessor (
return@launch
}
callbackOnProgress(translation["download_started_toast"])
remoteSideContext.log.debug("downloading media")
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = downloadMetadata.downloadSource,
author = downloadMetadata.mediaAuthor,
hash = downloadMetadata.mediaIdentifier
)
).apply {
status = TaskStatus.RUNNING
addListener(PendingTaskListener(onCancel = {
coroutineContext.job.cancel()
}))
updateProgress("Downloading...")
}
runCatching {
if (downloadRequest.isAudioStream) {
val streamUrl = downloadRequest.inputMedias.first().content
val outputFile = File.createTempFile("audio_stream", ".mp3")
callbackOnProgress("Downloading audio stream")
pendingTask.updateProgress("Downloading audio stream")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
inputs = listOf(streamUrl),
output = outputFile,
audioStreamFormat = downloadRequest.audioStreamFormat
))
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
return@launch
downloadSemaphore.withPermit {
callbackOnProgress(translation["download_started_toast"])
remoteSideContext.log.debug("downloading media")
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = downloadMetadata.downloadSource,
author = downloadMetadata.mediaAuthor,
hash = downloadMetadata.mediaIdentifier
)
).apply {
status = TaskStatus.RUNNING
addListener(PendingTaskListener(onCancel = {
coroutineContext.job.cancel()
}))
updateProgress("Downloading...")
}
//first download all input medias into cache
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
it.key to DownloadedFile(it.value, FileType.fromFile(it.value))
}.toMap().toMutableMap()
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
runCatching {
if (downloadRequest.isAudioStream) {
val streamUrl = downloadRequest.inputMedias.first().content
val outputFile = File.createTempFile("audio_stream", ".mp3")
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
callbackOnProgress("Downloading audio stream")
pendingTask.updateProgress("Downloading audio stream")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
inputs = listOf(streamUrl),
output = outputFile,
audioStreamFormat = downloadRequest.audioStreamFormat
))
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
return@launch
}
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
//first download all input medias into cache
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
it.key to it.value
}.toMap().toMutableMap()
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
zipFile.file.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply {
outputStream().use {
inputStream.copyTo(it)
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
zipFile.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply {
outputStream().use {
inputStream.copyTo(it)
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = it
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = DownloadedFile(it, FileType.fromFile(it))
}
}
oldDownloadedMedias.forEach { (_, value) ->
value.delete()
}
shouldMergeOverlay = true
}
oldDownloadedMedias.forEach { (_, value) ->
value.file.delete()
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media, FileType.fromFile(media))
val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia))
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
shouldMergeOverlay = true
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media.file, media.fileType)
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
}
}

View File

@@ -127,15 +127,26 @@ class TaskManager(
fun getTaskByHash(hash: String?): Task? {
if (hash == null) return null
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
if (cursor.moveToNext()) {
return readTaskFromCursor(cursor)
return runBlocking {
suspendCoroutine { continuation ->
queueExecutor.execute {
runCatching {
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
if (cursor.moveToNext()) {
continuation.resumeWith(Result.success(readTaskFromCursor(cursor)))
} else {
continuation.resumeWith(Result.success(null))
}
}
}.onFailure {
continuation.resumeWith(Result.failure(it))
}
}
}
}
return null
}
fun getActiveTasks() = activeTasks
fun getActiveTasks(): Map<Long, PendingTask> = activeTasks
fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map<Long, Task> {
val tasks = mutableMapOf<Long, Task>()

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.zIndex
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
@Immutable
data class FloatingTopBarColors(
@@ -49,6 +50,10 @@ fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
}
}
/**
* Unified Floating Top Bar for Aphelion.
* Handles the signature morphing animation and provides a "Bottom Content" slot.
*/
@Composable
fun FloatingTopBar(
title: String,
@@ -56,16 +61,21 @@ fun FloatingTopBar(
onBack: (() -> Unit)? = null,
modifier: Modifier = Modifier,
scrollOffset: Int = 0,
enableMorph: Boolean = false,
containerAlpha: Float = 1f,
titleAlignment: Alignment.Horizontal = Alignment.Start,
actions: @Composable RowScope.() -> Unit = {},
bottomContent: @Composable ColumnScope.(Float) -> Unit = {},
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
) {
val haptic = LocalHapticFeedback.current
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val focusFactor by remember(scrollOffset) {
derivedStateOf { (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
val focusFactor by remember(scrollOffset, enableMorph) {
derivedStateOf {
if (!enableMorph) 0f
else (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
}
}
val morphingParams by remember(focusFactor, statusBarHeight) {
@@ -88,10 +98,10 @@ fun FloatingTopBar(
var hasSnapped by remember { mutableStateOf(false) }
LaunchedEffect(focusFactor) {
if (focusFactor >= 1f && !hasSnapped) {
if (focusFactor >= 1f && !hasSnapped && scrollOffset > 10) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
hasSnapped = true
} else if (focusFactor < 0.9f) {
} else if (focusFactor < 0.5f) {
hasSnapped = false
}
}
@@ -115,7 +125,7 @@ fun FloatingTopBar(
.fillMaxWidth()
.padding(horizontal = morphingParams.sidePadding)
.padding(top = morphingParams.containerTopPadding)
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
.background(
Brush.verticalGradient(
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
@@ -183,87 +193,91 @@ fun FloatingTopBar(
}
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = morphingParams.internalTopPadding)
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
.height(morphingParams.headerHeight),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (onBack != null) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onBack()
},
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = morphingParams.internalTopPadding)
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
.height(morphingParams.headerHeight),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (onBack != null) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onBack()
},
modifier = Modifier
.size(44.dp)
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
translationX = -morphingParams.horizontalShift.toPx()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
}
Column(
modifier = Modifier
.size(44.dp)
.weight(1f)
.padding(vertical = 2.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = titleAlignment
) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 19.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
PurrfectMarqueeText(
text = subtitle,
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
style = TextStyle(fontSize = 13.sp),
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
translationY = morphingParams.subtitleTranslationY.toPx()
alpha = morphingParams.subtitleAlpha
}
)
}
}
Row(
modifier = Modifier
.wrapContentWidth()
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
translationX = -morphingParams.horizontalShift.toPx()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = titleAlignment
) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 19.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
PurrfectMarqueeText(
text = subtitle,
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
style = TextStyle(fontSize = 13.sp),
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
translationY = morphingParams.subtitleTranslationY.toPx()
alpha = morphingParams.subtitleAlpha
if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx()
}
)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
actions()
}
}
Row(
modifier = Modifier
.wrapContentWidth()
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx()
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
actions()
}
bottomContent(focusFactor)
}
}
}

View File

@@ -2,17 +2,8 @@ package me.eternal.purrfectsnap.ui.manager.pages
import android.content.Intent
import android.graphics.drawable.ColorDrawable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -41,13 +32,14 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.compose.ui.window.Dialog
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle
@@ -58,95 +50,220 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.download.DownloadProcessor
import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
import me.eternal.purrfectsnap.common.data.download.MediaDownloadSource
import me.eternal.purrfectsnap.common.data.download.createNewFilePath
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.ktx.longHashCode
import me.eternal.purrfectsnap.download.DownloadProcessor
import me.eternal.purrfectsnap.common.data.download.createNewFilePath
import me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver
import me.eternal.purrfectsnap.download.FFMpegProcessor
import me.eternal.purrfectsnap.task.*
import me.eternal.purrfectsnap.task.PendingTask
import me.eternal.purrfectsnap.task.PendingTaskListener
import me.eternal.purrfectsnap.task.Task
import me.eternal.purrfectsnap.task.TaskStatus
import me.eternal.purrfectsnap.task.TaskType
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.*
import java.io.File
import java.util.UUID
import kotlin.math.absoluteValue
import kotlin.text.Regex
class TasksRootSection : Routes.Route() {
internal var activeTasks by mutableStateOf(listOf<PendingTask>())
internal lateinit var recentTasks: MutableList<Task>
internal var lastFetchedTaskId: Long? by mutableStateOf(null)
internal var recentTasks = mutableStateListOf<Task>()
internal val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
internal var lastFetchedTaskId: Long? by mutableStateOf(null)
internal fun isRecentTasksInitialized(): Boolean = ::recentTasks.isInitialized
internal fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) {
scope.launch(Dispatchers.IO) {
activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList()
}
internal fun fetchActiveTasks(scope: CoroutineScope) {
activeTasks = context.taskManager.getActiveTasks().values.toList()
}
internal fun fetchNewRecentTasks(scope: CoroutineScope = context.coroutineScope) {
scope.launch(Dispatchers.IO) {
val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20)
if (tasks.isNotEmpty()) {
lastFetchedTaskId = tasks.keys.last()
val activeTaskIds = activeTasks.map { it.taskId }
recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values)
internal fun fetchNewRecentTasks() {
val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20)
if (tasks.isEmpty()) return
lastFetchedTaskId = tasks.keys.last()
val activeTaskHashes = activeTasks.map { it.task.hash }
val existingHashes = recentTasks.map { it.hash }
val newTasks = tasks.values.filter { it.hash !in activeTaskHashes && it.hash !in existingHashes }
recentTasks.addAll(newTasks)
}
internal fun refreshRecentTasks() {
val tasks = context.taskManager.fetchStoredTasks(Long.MAX_VALUE, limit = 20)
val activeTaskHashes = activeTasks.map { it.task.hash }
val newTasks = tasks.values.filter { it.hash !in activeTaskHashes }
recentTasks.clear()
recentTasks.addAll(newTasks)
lastFetchedTaskId = tasks.keys.lastOrNull()
}
internal fun isRecentTasksInitialized() = true
override val init: () -> Unit = {
recentTasks = mutableStateListOf()
}
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@TasksRootSection.TasksScreen(nav)
}
}
}
internal fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) {
val firstTask = selection.first().first
@Composable
internal fun TasksRootSection.TasksScreen(nav: NavBackStackEntry) {
val listState = rememberLazyListState()
var controlsHeight by remember { mutableStateOf(100.dp) }
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()
val computedScrollOffset by remember {
derivedStateOf {
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
else listState.firstVisibleItemScrollOffset
}
}
context.coroutineScope.launch {
val filesToMerge = mutableListOf<File>()
LaunchedEffect(computedScrollOffset) {
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
if (isAphelion) {
routes.navigation?.globalScrollOffset = computedScrollOffset
} else {
routes.navigation?.globalScrollOffset = 0
}
}
selection.forEach { (task, documentFile) ->
val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also {
it.deleteOnExit()
}
DisposableEffect(Unit) {
onDispose {
routes.navigation?.globalScrollOffset = 0
}
}
runCatching {
pendingTask.updateProgress("Copying ${documentFile.name}")
context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
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())
LaunchedEffect(Unit) {
refreshRecentTasks()
while (true) {
fetchActiveTasks(this)
delay(2000)
}
}
val shouldFetchMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > (totalItemsNumber - 5)
}
}
LaunchedEffect(shouldFetchMore) {
if (shouldFetchMore) {
fetchNewRecentTasks()
}
}
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().background(PurrfectPalette.backgroundGradient))
Column(modifier = Modifier.fillMaxSize()) {
Spacer(Modifier.height(controlsHeight))
Surface(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 16.dp,
bottom = routes.bottomPadding + 16.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
item {
AphelionTasksEmptyState(translation["no_tasks"])
}
outputStream.flush()
filesToMerge.add(tempFile)
}
items(activeTasks, key = { it.task.hash }) { pendingTask ->
TaskCard(
modifier = Modifier.fillMaxWidth(),
task = pendingTask.task,
pendingTask = pendingTask
)
}
items(recentTasks.filter { task -> activeTasks.none { it.task.hash == task.hash } }, key = { it.hash }) { task ->
TaskCard(
modifier = Modifier.fillMaxWidth(),
task = task
)
}
}
}.onFailure {
pendingTask.fail("Failed to copy file $documentFile to $tempFile")
filesToMerge.forEach { it.delete() }
return@launch
}
Spacer(Modifier.height(12.dp))
}
val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also {
it.deleteOnExit()
Column(modifier = Modifier.headerHeightTracker { controlsHeight = it }) {
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = translation["manager.routes.tasks"],
subtitle = translation["tasks_tagline"],
scrollOffset = computedScrollOffset,
enableMorph = true,
actions = {
topBarActions()
}
)
}
}
}
internal fun mergeSelection(files: List<Pair<Task, DocumentFile>>) {
val taskHash = System.nanoTime().toString(36)
val firstTask = files.first().first
val pendingTask = context.taskManager.createPendingTask(Task(
type = TaskType.DOWNLOAD,
title = firstTask.title,
author = firstTask.author,
hash = taskHash
)).apply {
status = TaskStatus.RUNNING
}
context.coroutineScope.launch(Dispatchers.IO) {
val filesToMerge = files.mapNotNull { (_, documentFile) ->
val tempFile = File.createTempFile("merge", ".tmp")
context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
tempFile
}
val mergedFile = File.createTempFile("merged", ".mp4")
runCatching {
context.shortToast(translation.format("merge_files_toast", "count" to filesToMerge.size.toString()))
FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
@@ -212,7 +329,6 @@ class TasksRootSection : Routes.Route() {
message: String,
showDeleteFiles: Boolean,
deleteFilesChecked: Boolean,
tasksTranslation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper,
onToggleDeleteFiles: (Boolean) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
@@ -220,6 +336,7 @@ class TasksRootSection : Routes.Route() {
if (!visible) return
val dialogShape = RoundedCornerShape(24.dp)
val haptic = LocalHapticFeedback.current
val borderGradient = remember {
Brush.linearGradient(
listOf(
@@ -237,24 +354,44 @@ class TasksRootSection : Routes.Route() {
shadowElevation = 20.dp,
border = BorderStroke(1.dp, borderGradient)
) {
Box(modifier = Modifier.background(PurrfectPalette.cardOverlay, dialogShape)) {
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = CircleShape, color = Color.White.copy(alpha = 0.08f), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) {
Box(modifier = Modifier.size(56.dp).background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.38f), PurrfectPalette.glowSecondary.copy(alpha = 0.32f)))), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Filled.Warning, contentDescription = null, modifier = Modifier.size(28.dp), tint = Color.White)
}
}
Column {
Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(text = message, fontSize = 13.sp, color = PurrfectPalette.textSecondary)
}
Box(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, dialogShape)
) {
Column(
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = Color(0xFFFF6B9B),
modifier = Modifier.size(28.dp)
)
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White
)
}
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = PurrfectPalette.textSecondary
)
if (showDeleteFiles) {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.04f),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.05f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
@@ -262,14 +399,20 @@ class TasksRootSection : Routes.Route() {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleDeleteFiles(!deleteFilesChecked) }
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleDeleteFiles(!deleteFilesChecked)
}
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Checkbox(
checked = deleteFilesChecked,
onCheckedChange = { onToggleDeleteFiles(it) },
onCheckedChange = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleDeleteFiles(it)
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White,
@@ -278,26 +421,47 @@ class TasksRootSection : Routes.Route() {
)
Column {
Text(
text = tasksTranslation["delete_files_option"],
text = context.translation["delete_files_option"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
Text(
text = tasksTranslation.getOrNull("delete_files_option_hint") ?: "Permanently remove the original files from storage",
text = context.translation["delete_files_option_hint"] ?: "Also remove downloaded files",
color = PurrfectPalette.textSecondary,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { onDismiss() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.08f), contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
Text(text = context.translation["button.cancel"])
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
Button(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(context.translation["button.negative"])
}
Button(onClick = { onConfirm() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) {
Text(text = context.translation["button.positive"], fontWeight = FontWeight.Bold)
Button(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onConfirm()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(context.translation["button.positive"])
}
}
}
@@ -309,13 +473,38 @@ class TasksRootSection : Routes.Route() {
@Composable
internal fun TasksEmptyState(text: String) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 60.dp),
modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(shape = CircleShape, color = Color.White.copy(alpha = 0.08f), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) {
Box(modifier = Modifier.size(58.dp).background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.32f), PurrfectPalette.glowSecondary.copy(alpha = 0.28f))), CircleShape), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.CheckCircle, contentDescription = text, tint = Color.White)
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Box(
modifier = Modifier
.size(58.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
),
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = text,
tint = Color.White
)
}
}
Text(
@@ -326,6 +515,67 @@ class TasksRootSection : Routes.Route() {
}
}
@Composable
internal fun AphelionTasksEmptyState(text: String) {
TasksEmptyState(text)
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {
var showConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
TopBarActionButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(taskSelection.toList().also {
taskSelection.clear()
}.map { it.first to it.second!! })
},
icon = Icons.Filled.Merge,
text = translation["merge_button"]
)
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"])
}
if (showConfirmDialog) {
var alsoDeleteFiles by remember { mutableStateOf(false) }
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText,
message = messageText,
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
clearTasks(alsoDeleteFiles, coroutineScope)
},
onDismiss = { showConfirmDialog = false }
)
}
}
@Composable
internal fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
@@ -333,26 +583,46 @@ class TasksRootSection : Routes.Route() {
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
var taskProgress by remember { mutableIntStateOf(-1) }
val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
val haptic = LocalHapticFeedback.current
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 {
val docVal = task.extra?.toUri()
val documentFile by rememberAsyncMutableState(defaultValue = null as DocumentFile?, keys = arrayOf(taskStatus.name)) {
if (docVal == null) null
else DocumentFile.fromSingleUri(context.androidContext, docVal)?.apply {
documentFileMimeType = type ?: ""
isDocumentFileReadable = canRead()
}
}
val listener = remember { PendingTaskListener(
onStateChange = { taskStatus = it },
onProgress = { label, progress -> taskProgressLabel = label; taskProgress = progress }
onStateChange = {
taskStatus = it
},
onProgress = { label, progress ->
taskProgressLabel = label
taskProgress = progress
}
) }
LaunchedEffect(Unit) { pendingTask?.addListener(listener) }
DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } }
LaunchedEffect(Unit) {
pendingTask?.addListener(listener)
}
DisposableEffect(Unit) {
onDispose {
pendingTask?.removeListener(listener)
}
}
fun toggleSelection() {
if (isSelected) { taskSelection.removeIf { it.first == task }; return }
if (isSelected) {
taskSelection.removeIf { it.first == task }
return
}
taskSelection.add(task to documentFile)
}
@@ -373,13 +643,29 @@ class TasksRootSection : Routes.Route() {
val cardModifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = { if (taskSelection.isNotEmpty()) toggleSelection() else openFile() },
onLongPress = { if (taskSelection.isNotEmpty()) openFile() else toggleSelection() }
onTap = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
toggleSelection()
return@detectTapGestures
}
openFile()
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
openFile()
return@detectTapGestures
}
toggleSelection()
}
)
}
.let {
if (isSelected) {
it.border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large).clip(MaterialTheme.shapes.large)
it
.border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large)
.clip(MaterialTheme.shapes.large)
} else it
}
@@ -397,98 +683,163 @@ class TasksRootSection : Routes.Route() {
taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel
else -> Icons.Filled.Info
}
val chipColors = when {
isActive -> AssistChipDefaults.assistChipColors(
containerColor = Color.White.copy(alpha = 0.08f),
labelColor = Color.White
)
taskStatus == TaskStatus.SUCCESS -> AssistChipDefaults.assistChipColors()
taskStatus == TaskStatus.FAILURE -> AssistChipDefaults.assistChipColors(
containerColor = Color(0xFFFF6B9B).copy(alpha = 0.18f),
labelColor = Color.White
)
taskStatus == TaskStatus.CANCELLED -> AssistChipDefaults.assistChipColors(
containerColor = Color.White.copy(alpha = 0.06f),
labelColor = PurrfectPalette.textSecondary
)
else -> AssistChipDefaults.assistChipColors()
}
Surface(
modifier = cardModifier.fillMaxWidth().padding(vertical = 4.dp),
modifier = cardModifier,
shape = MaterialTheme.shapes.large,
color = Color.White.copy(alpha = 0.04f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(modifier = Modifier.padding(14.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(modifier = Modifier.size(54.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.06f)).border(1.dp, Color.White.copy(alpha = 0.1f), CircleShape), contentAlignment = Alignment.Center) {
Icon(imageVector = if (task.type == TaskType.DOWNLOAD) Icons.Default.Download else Icons.Default.Transform, contentDescription = null, tint = Color.White.copy(alpha = 0.6f), modifier = Modifier.size(24.dp))
Box(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
modifier = Modifier.size(56.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
PurrfectPalette.glowSecondary.copy(alpha = 0.18f)
)
)
),
contentAlignment = Alignment.Center
) {
documentFile?.let { doc ->
if (documentFileMimeType.contains("image")) {
Image(
painter = rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current)
.data(doc.uri)
.size(120)
.crossfade(true)
.build()
),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(18.dp))
)
} else {
Icon(
imageVector = when {
!isDocumentFileReadable -> Icons.Filled.DeleteOutline
documentFileMimeType.contains("video") -> Icons.Filled.Videocam
documentFileMimeType.contains("audio") -> Icons.Filled.MusicNote
else -> Icons.Filled.FileCopy
},
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
} ?: run {
Icon(
imageVector = when (task.type) {
TaskType.DOWNLOAD -> Icons.Filled.Download
TaskType.CHAT_ACTION -> Icons.Filled.ChatBubble
TaskType.SCHEDULED_SEND -> Icons.Filled.Schedule
},
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.ExtraBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
task.author?.takeIf { it != "null" }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = PurrfectPalette.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (isActive) {
taskProgressLabel?.let {
Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White)
}
if (taskProgress != -1) {
LinearProgressIndicator(
progress = { taskProgress.toFloat() / 100f },
strokeCap = StrokeCap.Round,
modifier = Modifier.fillMaxWidth().height(6.dp),
color = PurrfectPalette.glowSecondary,
trackColor = Color.White.copy(alpha = 0.12f)
)
}
} else {
chipLabel?.let { label ->
AssistChip(
onClick = {},
enabled = false,
leadingIcon = chipIcon?.let { { Icon(it, null, modifier = Modifier.size(14.dp)) } },
label = { Text(label, fontSize = 11.sp) },
colors = chipColors,
shape = RoundedCornerShape(10.dp)
)
}
}
}
if (isActive) {
CircularProgressIndicator(progress = { (taskProgress / 100f).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxSize(), color = PurrfectPalette.glowPrimary, strokeWidth = 3.dp, trackColor = Color.Transparent, strokeCap = StrokeCap.Round)
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
pendingTask?.cancel()
}) {
Icon(Icons.Filled.Close, null, tint = Color(0xFFFF6B9B))
}
} else if (taskStatus == TaskStatus.SUCCESS) {
Icon(Icons.Filled.Check, null, tint = PurrfectPalette.glowSecondary)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(text = task.title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = taskProgressLabel ?: task.author ?: "", fontSize = 12.sp, color = PurrfectPalette.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (chipLabel != null || chipIcon != null) {
AssistChip(onClick = {}, label = { chipLabel?.let { Text(it) } }, leadingIcon = { chipIcon?.let { Icon(it, null, modifier = Modifier.size(18.dp)) } }, colors = AssistChipDefaults.assistChipColors(labelColor = Color.White, leadingIconContentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), shape = CircleShape)
}
}
}
}
override val init: () -> Unit = {
recentTasks = mutableStateListOf()
}
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
}
key(themeId) {
LaunchedEffect(themeId) {
routes.navigation?.globalScrollOffset = 0
}
with(ManagerTheme.fromId(themeId).theme) {
this@TasksRootSection.TasksScreen(nav)
}
}
}
override val topBarActions: @Composable RowScope.() -> Unit = {
var showConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
if (taskSelection.isNotEmpty()) {
val hapticFeedback = LocalHapticFeedback.current
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
TopBarActionButton(onClick = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); mergeSelection(taskSelection.toList().also { taskSelection.clear() }.map { it.first to it.second!! }) }, icon = Icons.Filled.Merge, text = translation["merge_button"])
}
}
IconButton(onClick = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); showConfirmDialog = true }) {
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"])
}
}
if (showConfirmDialog) {
var alsoDeleteFiles by remember { mutableStateOf(false) }
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText ?: "",
message = messageText ?: "",
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
tasksTranslation = translation,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
clearTasks(alsoDeleteFiles, coroutineScope)
},
onDismiss = { showConfirmDialog = false }
)
}
}
}

View File

@@ -1,7 +1,7 @@
package me.eternal.purrfectsnap.ui.manager.pages.features
import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.LocalIndication
@@ -77,7 +77,6 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.theme.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.*
import org.json.JSONArray
import org.json.JSONObject
@@ -124,7 +123,7 @@ class FeaturesRootSection : Routes.Route() {
it.key.dataType.type == DataProcessors.Type.CONTAINER &&
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
) {
containers[it.key.name] = PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>)
containers[it.key.name] = (it.key to it.value).toPropertyPair() as PropertyPair<Any>
queryContainerRecursive(it.value.get() as ConfigContainer)
}
}
@@ -148,7 +147,22 @@ class FeaturesRootSection : Routes.Route() {
return !propertyKey.params.flags.contains(ConfigFlag.HIDDEN)
}
internal fun navigateToMainRoot() {
internal fun getFolderReadablePath(context: android.content.Context, folderUri: String?): String? {
if (folderUri == null) return null
return try {
val uri = android.net.Uri.parse(folderUri)
val path = uri.path ?: return folderUri
if (path.contains("tree/")) {
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
} else {
folderUri
}
} catch (e: Exception) {
folderUri
}
}
fun navigateToMainRoot() {
routes.navController.navigate(routeInfo.id, NavOptions.Builder()
.setPopUpTo(routes.navController.graph.findStartDestination().id, false)
.setLaunchSingleTop(true)
@@ -215,7 +229,7 @@ class FeaturesRootSection : Routes.Route() {
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
)
}.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
}.map { (it.key to it.value).toPropertyPair() }
PropertiesView(
properties = properties,
@@ -336,21 +350,6 @@ class FeaturesRootSection : Routes.Route() {
val propertyValue = property.value
fun persistConfig() = context.config.writeConfig()
fun getFolderReadablePath(context: android.content.Context, folderUri: String?): String? {
if (folderUri == null) return null
return try {
val uri = android.net.Uri.parse(folderUri)
val path = uri.path ?: return folderUri
if (path.contains("tree/")) {
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
} else {
folderUri
}
} catch (e: Exception) {
folderUri
}
}
if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
registerDialogOnClickCallback()
dialogComposable = {
@@ -502,20 +501,14 @@ class FeaturesRootSection : Routes.Route() {
}
if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
val folderUri = propertyValue.get() as? String
val readablePath = remember(folderUri) { getFolderReadablePath(context.androidContext, folderUri) }
ValueGlowChip(
text = readablePath ?: folderUri ?: "None",
leadingIcon = Icons.Filled.FolderOpen,
modifier = Modifier.widthIn(min = 52.dp, max = 160.dp),
onClick = registerClickCallback {
routes.activityLauncher.chooseFolder { uri ->
propertyValue.setAny(uri)
persistConfig()
}
IconButton(onClick = registerClickCallback {
routes.activityLauncher.chooseFolder { uri ->
propertyValue.setAny(uri)
persistConfig()
}
)
}) {
Icon(Icons.Filled.FolderOpen, contentDescription = null, tint = Color.White)
}
return
}
@@ -685,48 +678,52 @@ class FeaturesRootSection : Routes.Route() {
@Composable
internal fun ValueGlowChip(
text: String,
leadingIcon: ImageVector? = null,
modifier: Modifier = Modifier.size(52.dp),
onClick: () -> Unit
) {
Surface(
modifier = modifier
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.clickable { onClick() },
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.5f), PurrfectPalette.glowSecondary.copy(alpha = 0.35f))))
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.radialGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.25f), Color.Transparent))),
.background(
Brush.radialGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
Color.Transparent
)
)
),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (leadingIcon != null) {
Icon(imageVector = leadingIcon, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = text,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = text,
color = Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
@Composable
internal fun PropertyCard(property: PropertyPair<*>, onOpen: (() -> Unit)? = null) {
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
var clickCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
val noticeColorMap = remember {
mapOf(
@@ -857,17 +854,15 @@ class FeaturesRootSection : Routes.Route() {
) {
PropertyAction(property, registerClickCallback = { callback ->
if (property.key.propertyTranslationPath().startsWith("rules.properties")) {
val ruleCallback: () -> Unit = {
clickCallback = {
routes.manageRuleFeature.navigate {
put("rule_type", property.key.name)
}
}
clickCallback = ruleCallback
ruleCallback // return the callback
} else {
clickCallback = callback
callback // return the callback
return@PropertyAction clickCallback!!
}
clickCallback = callback
callback
})
}
}
@@ -888,8 +883,10 @@ class FeaturesRootSection : Routes.Route() {
onSearchQueryChange: (String) -> Unit,
onBack: (() -> Unit)? = null,
scrollOffset: Int = 0,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onHeightMeasured: (androidx.compose.ui.unit.Dp) -> Unit = {}
) {
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
var showSearchBar by rememberSaveable { mutableStateOf(isSearchResults) }
val focusRequester = remember { FocusRequester() }
val isOverlay = activeSectionTitle != null
@@ -976,6 +973,7 @@ class FeaturesRootSection : Routes.Route() {
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
context.config.reset()
context.config.writeConfig()
context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"] ?: "Reset successful")
showResetConfirmationDialog = false
},
@@ -1045,7 +1043,7 @@ class FeaturesRootSection : Routes.Route() {
val headerTitle = activeSectionTitle ?: translation["manager.routes.features"] ?: "Features"
val subtitleText = when {
searchKeyword != null -> translation["search_button"] ?: "Search"
isSearchResults -> translation["search_button"] ?: "Search"
!activeSectionSubtitle.isNullOrBlank() -> activeSectionSubtitle
else -> translation["manager.sections.features.subtitle"] ?: ""
}
@@ -1063,137 +1061,229 @@ class FeaturesRootSection : Routes.Route() {
}
}
Column(modifier = modifier.headerHeightTracker { controlsHeight = it }) {
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = headerTitle,
subtitle = if (showSearchBar) null else subtitleText,
onBack = onBack,
scrollOffset = scrollOffset,
enableMorph = true,
actions = {
if (showSearchBar) {
TextField(
value = searchValue,
onValueChange = { keywordValue ->
searchValue = keywordValue
if (keywordValue.text.isEmpty()) {
updateSearch("", record = false)
} else {
updateSearch(keywordValue.text, record = false)
}
},
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
singleLine = true,
placeholder = { Text(text = translation["search_button"] ?: "Search", color = Color(0xFFE0DCFF)) },
leadingIcon = {
Column(modifier = modifier.headerHeightTracker { onHeightMeasured(it) }) {
if (isAphelion) {
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = headerTitle,
subtitle = if (showSearchBar) null else subtitleText,
onBack = onBack,
scrollOffset = scrollOffset,
enableMorph = true,
actions = {
if (showSearchBar) {
TextField(
value = searchValue,
onValueChange = { keywordValue ->
searchValue = keywordValue
if (keywordValue.text.isEmpty()) {
updateSearch("", record = false)
} else {
updateSearch(keywordValue.text, record = false)
}
},
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
singleLine = true,
placeholder = { Text(text = translation["search_button"] ?: "Search", color = Color(0xFFE0DCFF)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = Color.White
)
},
trailingIcon = {
if (searchValue.text.isNotEmpty()) {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
searchValue = TextFieldValue("", TextRange(0))
updateSearch("", record = false)
if (isSearchResults) {
if (isOverlay) {
routes.navController.popBackStack(routeInfo.id, false)
}
} else {
showSearchBar = false
}
}) {
Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White)
}
}
},
keyboardActions = KeyboardActions(
onDone = {
updateSearch(searchValue.text, record = true)
}
),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color.White,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
disabledTextColor = Color.White.copy(alpha = 0.65f),
focusedPlaceholderColor = Color(0xFFE0DCFF),
unfocusedPlaceholderColor = Color(0xFFE0DCFF),
focusedLeadingIconColor = Color.White,
unfocusedLeadingIconColor = Color.White.copy(alpha = 0.9f),
focusedTrailingIconColor = Color.White,
unfocusedTrailingIconColor = Color.White.copy(alpha = 0.9f)
)
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
} else {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showSearchBar = true
}) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = Color.White
)
},
trailingIcon = {
if (searchValue.text.isNotEmpty()) {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
searchValue = TextFieldValue("", TextRange(0))
updateSearch("", record = false)
if (isSearchResults) {
if (isOverlay) {
routes.navController.popBackStack(routeInfo.id, false)
}
} else {
showSearchBar = false
}
}) {
Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White)
}
}
if (context.activity != null) {
Box {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showExportDropdownMenu = !showExportDropdownMenu
}) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = null,
tint = PurrfectPalette.glowSecondary
)
}
DropdownMenu(
expanded = showExportDropdownMenu,
onDismissRequest = { showExportDropdownMenu = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
shape = RoundedCornerShape(14.dp),
tonalElevation = 8.dp,
shadowElevation = 12.dp
) {
actions.forEach { (name, icon, action) ->
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = icon,
contentDescription = null,
tint = PurrfectPalette.glowPrimary
)
},
text = { Text(text = name ?: "", color = Color.White) },
onClick = {
action()()
showExportDropdownMenu = false
},
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
}
}
},
keyboardActions = KeyboardActions(
onDone = {
updateSearch(searchValue.text, record = true)
}
),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color.White,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
disabledTextColor = Color.White.copy(alpha = 0.65f),
focusedPlaceholderColor = Color(0xFFE0DCFF),
unfocusedPlaceholderColor = Color(0xFFE0DCFF),
focusedLeadingIconColor = Color.White,
unfocusedLeadingIconColor = Color.White.copy(alpha = 0.9f),
focusedTrailingIconColor = Color.White,
unfocusedTrailingIconColor = Color.White.copy(alpha = 0.9f)
)
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
} else {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showSearchBar = true
}) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = Color.White
)
}
}
}
)
} else {
val topBarShape = RoundedCornerShape(26.dp)
val topBarBackground = remember { PurrfectPalette.cardOverlay }
val topBarBorder = remember {
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
}
if (context.activity != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 14.dp, vertical = 12.dp)
.zIndex(1f)
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = topBarShape,
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, topBarBorder),
tonalElevation = 0.dp,
shadowElevation = 8.dp
) {
Box {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showExportDropdownMenu = !showExportDropdownMenu
}) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = null,
tint = PurrfectPalette.glowSecondary
)
}
DropdownMenu(
expanded = showExportDropdownMenu,
onDismissRequest = { showExportDropdownMenu = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
shape = RoundedCornerShape(14.dp),
tonalElevation = 8.dp,
shadowElevation = 12.dp
Box(
modifier = Modifier
.matchParentSize()
.clip(topBarShape)
.background(topBarBackground)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
actions.forEach { (name, icon, action) ->
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = icon,
contentDescription = null,
tint = PurrfectPalette.glowPrimary
)
},
text = { Text(text = name ?: "", color = Color.White) },
onClick = {
action()()
showExportDropdownMenu = false
},
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = context.translation["common.back"],
tint = Color.White
)
}
}
if (showSearchBar) {
TextField(
value = searchValue,
onValueChange = { keywordValue ->
searchValue = keywordValue
updateSearch(keywordValue.text, record = false)
},
modifier = Modifier.weight(1f).focusRequester(focusRequester),
singleLine = true,
placeholder = { Text(text = translation["search_button"] ?: "Search", color = Color(0xFFE0DCFF)) },
colors = TextFieldDefaults.colors(focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, cursorColor = Color.White, focusedTextColor = Color.White, unfocusedTextColor = Color.White)
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
} else {
Column(modifier = Modifier.weight(1f)) {
Text(
text = headerTitle,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
Text(
text = subtitleText,
color = Color(0xFFCEC8FF),
fontSize = 12.sp
)
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showSearchBar = !showSearchBar
}) {
Icon(imageVector = if (showSearchBar) Icons.Filled.Close else Icons.Filled.Search, contentDescription = null, tint = Color.White)
}
}
}
}
}
)
}
if (showSearchBar && combinedSuggestions.isNotEmpty()) {
Surface(
@@ -1263,21 +1353,14 @@ class FeaturesRootSection : Routes.Route() {
val density = LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
// Claude Fix 2: Backstack-scoped scroll state
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
val listState = rememberSaveable(
navBackStackEntry?.id,
saver = LazyListState.Saver
) {
LazyListState()
}
val listState = rememberLazyListState()
val sharedSearchHistory = remember { mutableStateListOf<String>().apply { addAll(loadSearchHistory()) } }
var liveSearchQuery by rememberSaveable { mutableStateOf(searchKeyword.orEmpty()) }
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
val globalSearchProperties = remember(enableGlobalSearch) {
if (enableGlobalSearch) {
allProperties.filter { isSearchVisibleProperty(it.key) }.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
allProperties.filter { isSearchVisibleProperty(it.key) }.map { (it.key to it.value).toPropertyPair() } as List<PropertyPair<Any>>
} else {
emptyList()
}
@@ -1329,12 +1412,10 @@ class FeaturesRootSection : Routes.Route() {
contentPadding = PaddingValues(
start = 6.dp,
end = 6.dp,
top = controlsHeight,
bottom = routes.bottomPadding
)
) {
item {
Spacer(modifier = Modifier.height(controlsHeight + 12.dp))
}
if (displayProperties.isEmpty()) {
item { EmptyState(isActiveSearch) }
} else {
@@ -1359,7 +1440,7 @@ class FeaturesRootSection : Routes.Route() {
onSearchQueryChange = { liveSearchQuery = it },
onBack = onBack,
scrollOffset = computedScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
onHeightMeasured = { controlsHeight = it }
)
}
}
@@ -1500,7 +1581,7 @@ class FeaturesRootSection : Routes.Route() {
) {
PropertiesView(
properties = remember {
configContainer.properties.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }.filter {
configContainer.properties.map { (it.key to it.value).toPropertyPair() as PropertyPair<Any> }.filter {
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
}
},
@@ -1531,7 +1612,7 @@ class FeaturesRootSection : Routes.Route() {
if (a.isEmpty()) return b.length
if (b.isEmpty()) return a.length
val prev = IntArray(b.length + 1) { it }
val curr = IntArray(b.length + 1)
val curr = IntArray(b.length + 1) { it }
for (i in a.indices) {
curr[0] = i + 1
for (j in b.indices) {
@@ -1584,6 +1665,3 @@ class FeaturesRootSection : Routes.Route() {
saveSearchHistory(history.toList())
}
}

View File

@@ -49,7 +49,7 @@ class HomeAbout : Routes.Route() {
}
LaunchedEffect(Unit) {
context.shortToast(translation["about_magic_toast"])
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
}
key(themeId) {

View File

@@ -777,3 +777,6 @@ class HomeRootSection : Routes.Route() {
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -19,22 +20,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
@@ -44,6 +38,7 @@ import androidx.compose.ui.graphics.Canvas
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.input.pointer.pointerInteropFilter
@@ -155,7 +150,6 @@ class RetroGameScreen : Routes.Route() {
}
LaunchedEffect(Unit) {
context.shortToast(translation["about_magic_toast"]?:"")
resetGame()
while (true) {
delay(16)
@@ -197,6 +191,8 @@ class RetroGameScreen : Routes.Route() {
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
Box(
modifier = Modifier
.fillMaxSize()
@@ -208,10 +204,50 @@ class RetroGameScreen : Routes.Route() {
.padding(bottom = bottomPadding),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
FloatingTopBar(
title = translation["title"],
onBack = { routes.navController.popBackStack() }
)
if (isAphelion) {
FloatingTopBar(
title = translation["title"] ?: "Retro Flight",
onBack = { routes.navController.popBackStack() }
)
} else {
val shape = RoundedCornerShape(26.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = shape,
color = Color.White.copy(alpha = 0.07f),
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.55f), PurrfectPalette.glowSecondary.copy(alpha = 0.35f)))),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }, modifier = Modifier.size(42.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["title"] ?: "",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier

View File

@@ -900,3 +900,6 @@ class ScriptingRootSection : Routes.Route() {
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
}

View File

@@ -569,3 +569,6 @@ class SocialRootSection : Routes.Route() {
}
}

View File

@@ -51,7 +51,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
}
val scrollState = rememberScrollState()
val aboutStory = remember { translation["about_story"]?.trim() ?: "" }
val horizontalPadding = 24.dp
val horizontalPadding = 24.dp
val bottomPadding = routes.bottomPadding
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
@@ -74,10 +74,12 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = controlsHeight, bottom = bottomPadding + 4.dp),
.padding(bottom = bottomPadding + 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(controlsHeight))
Surface(
modifier = Modifier
.padding(horizontal = horizontalPadding)
@@ -111,9 +113,6 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
}
tapCount.intValue += 1
lastTapTime.longValue = now
if (tapCount.intValue >= 3 && tapCount.intValue < 5) {
context.shortToast(translation.format("magic_toast", "count" to (5 - tapCount.intValue).toString()))
}
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
routes.retroGame.navigate()
@@ -216,7 +215,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -231,6 +230,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About Us",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}

View File

@@ -122,6 +122,7 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
title = context.translation["manager.routes.home_logs"] ?: "Logs",
onBack = { routes.navController.popBackStack() },
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (isRefreshing) {

View File

@@ -8,7 +8,8 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.animation.AnimatedVisibility
@@ -47,6 +48,7 @@ import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.saveFile
@@ -58,7 +60,7 @@ import java.net.URLEncoder
@Composable
fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val listState = rememberLazyListState()
val hapticFeedback = LocalHapticFeedback.current
var controlsHeight by remember { mutableStateOf(100.dp) }
var showResetSetupDialog by remember { mutableStateOf(false) }
@@ -69,8 +71,15 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
)
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
val computedScrollOffset by remember {
derivedStateOf {
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
else listState.firstVisibleItemScrollOffset
}
}
LaunchedEffect(computedScrollOffset) {
routes.navigation?.globalScrollOffset = computedScrollOffset
}
Box(
@@ -106,162 +115,167 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = controlsHeight, bottom = routes.bottomPadding + 24.dp)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = routes.bottomPadding + 24.dp)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// THEME SWITCHER
GlassCard {
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp)
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
Switch(
checked = currentThemeId == "APHELION",
onCheckedChange = { isAphelion ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) }
val newId = if (isAphelion) "APHELION" else "LEGACY"
context.config.root.global.uiSettings.managerTheme.set(newId)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
}
}
item {
Spacer(Modifier.height(controlsHeight))
}
// ACTIONS
GlassCard {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
}
// UI SETTINGS
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
}
}
// UPDATES
GlassCard {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
item {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// THEME SWITCHER
GlassCard {
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["auto_update_check"], fontSize = 14.sp)
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp)
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
Switch(
checked = currentThemeId == "APHELION",
onCheckedChange = { isAphelion ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) }
val newId = if (isAphelion) "APHELION" else "LEGACY"
context.config.root.global.uiSettings.managerTheme.set(newId)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
}
AnimatedVisibility(visible = autoUpdateCheck) {
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
}
// ACTIONS
GlassCard {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
}
// UI SETTINGS
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
}
}
}
// RESET SETUP
GlassCard {
RowTitle(title = translation["reset_setup_title"])
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
}
}
// MESSAGE LOGGER
GlassCard {
RowTitle(title = translation["message_logger_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
// UPDATES
GlassCard {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["auto_update_check"], fontSize = 14.sp)
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
}
}
}
// FRIEND NOTES
GlassCard {
RowTitle(title = translation["friend_notes_title"])
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
}
}
}
}
// DEBUG
GlassCard {
RowTitle(title = translation["debug_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1f)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
AnimatedVisibility(visible = autoUpdateCheck) {
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
}
}
}
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
}
}
// RESET SETUP
GlassCard {
RowTitle(title = translation["reset_setup_title"])
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
}
}
// MESSAGE LOGGER
GlassCard {
RowTitle(title = translation["message_logger_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
}
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
}
}
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
// FRIEND NOTES
GlassCard {
RowTitle(title = translation["friend_notes_title"])
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
}
}
}
}
// DEBUG
GlassCard {
RowTitle(title = translation["debug_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1f)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
}
}
}
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
}
}
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
}
}
}
@@ -269,10 +283,11 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
}
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_settings"] ?: "Settings",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
scrollOffset = computedScrollOffset,
enableMorph = true,
titleAlignment = Alignment.CenterHorizontally,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {

View File

@@ -58,12 +58,14 @@ import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
val scrollState = rememberLazyListState()
val haptic = LocalHapticFeedback.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(scrollState.firstVisibleItemScrollOffset, scrollState.firstVisibleItemIndex) {
val offset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset
@@ -95,98 +97,39 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
)
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.tasks"] ?: "Tasks",
subtitle = subtitle,
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
actions = {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White)
Text(
text = translation.format("running_count", "count" to activeTasks.size.toString()),
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp
)
}
}
Spacer(Modifier.width(8.dp))
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.1f),
modifier = Modifier
.padding(end = 8.dp)
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(
taskSelection.toList().also { taskSelection.clear() }
.map { it.first to it.second!! }
)
}
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.Merge,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
translation["merge_button"] ?: "Merge",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
}
}
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
)
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
}
Spacer(modifier = Modifier.height(8.dp))
// The "Structured Glass" Container (1:1 with build 33a7e8f)
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
.padding(top = 12.dp),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp, bottomStart = 0.dp, bottomEnd = 0.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 0.dp,
bottom = routes.bottomPadding
start = 10.dp,
end = 10.dp,
top = controlsHeight,
bottom = routes.bottomPadding + 20.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -209,6 +152,69 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
}
}
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.tasks"] ?: "Tasks",
subtitle = subtitle,
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Filled.PlaylistAddCheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Text(
text = activeTasks.size.toString(),
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 12.sp
)
}
}
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(
taskSelection.toList().also { taskSelection.clear() }
.map { it.first to it.second!! }
)
},
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
}
}
)
}
if (showConfirmDialog) {
@@ -222,11 +228,10 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText ?: "",
message = messageText ?: "",
title = titleText,
message = messageText,
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
tasksTranslation = translation,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
@@ -291,11 +296,14 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p
var documentFileMimeType by remember { mutableStateOf("") }
var isDocumentFileReadable by remember { mutableStateOf(true) }
val docVal = task.extra?.toUri()
val documentFile by rememberAsyncMutableState(
defaultValue = null as DocumentFile?,
keys = arrayOf(taskStatus.key)
keys = arrayOf(taskStatus.name)
) {
DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
if (docVal == null) null
else DocumentFile.fromSingleUri(context.androidContext, docVal)?.apply {
documentFileMimeType = type ?: ""
isDocumentFileReadable = canRead()
}
@@ -409,48 +417,32 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p
Row(modifier = Modifier.background(PurrfectPalette.cardOverlay, cardShape).padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.padding(end = 15.dp).size(50.dp).clipToBounds(), contentAlignment = Alignment.Center) {
var loadFailed by remember { mutableStateOf(false) }
documentFile?.let { doc ->
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
val imageRequest = ImageRequest.Builder(context.androidContext)
.data(doc.uri)
.cacheKey(doc.uri.toString())
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
.build()
Image(
painter = rememberAsyncImagePainter(
model = imageRequest,
imageLoader = context.imageLoader,
onState = { state ->
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
}
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
)
} else {
when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
}
}
} ?: run {
when (task.type) {
TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = null)
TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = null)
TaskType.SCHEDULED_SEND -> {
val active = !taskStatus.isFinalStage()
val rotation = if (active) {
val transition = rememberInfiniteTransition(label = "scheduled_send")
transition.animateFloat(initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)), label = "rotation").value
} else 0f
Box(modifier = Modifier.size(50.dp).clip(CircleShape).background(if (active) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.25f), PurrfectPalette.glowSecondary.copy(alpha = 0.22f))) else SolidColor(Color.White.copy(alpha = 0.06f))), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.Schedule, contentDescription = null, modifier = Modifier.size(28.dp).rotate(rotation), tint = if (active) PurrfectPalette.glowSecondary else PurrfectPalette.textSecondary)
val doc = documentFile
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && doc != null && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
val imageRequest = ImageRequest.Builder(context.androidContext)
.data(doc.uri)
.cacheKey(doc.uri.toString())
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
.build()
Image(
painter = rememberAsyncImagePainter(
model = imageRequest,
imageLoader = context.imageLoader,
onState = { state ->
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
}
}
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
)
} else {
when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
}
}
}

View File

@@ -657,10 +657,11 @@ object LegacyTheme : ThemeContract {
}
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(topPadding))
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
border = BorderStroke(1.dp, Brush.linearGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.05f)))),
@@ -869,10 +870,27 @@ object LegacyTheme : ThemeContract {
.verticalScroll(scrollState)
.padding(bottom = bottomPadding)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About",
onBack = { routes.navController.popBackStack() }
)
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Spacer(modifier = Modifier.height(topPadding))
Surface(
modifier = Modifier.padding(horizontal = pagePadding, vertical = 12.dp).fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
border = BorderStroke(1.dp, Brush.linearGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.05f)))),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
}
Text(text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About", fontWeight = FontWeight.ExtraBold, fontSize = 18.sp)
Spacer(modifier = Modifier.width(48.dp))
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -893,20 +911,17 @@ object LegacyTheme : ThemeContract {
text = translation["about_title"] ?: "About",
fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold,
color = PurrfectPalette.textPrimary,
color = Color.White,
fontFamily = avenirNext,
modifier = Modifier.clickable(interactionSource = tapSource, indication = null) {
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.value > 1500L) { tapCount.intValue = 0 }
tapCount.intValue += 1
lastTapTime.value = now
if (tapCount.intValue >= 3 && tapCount.intValue < 5) {
context.shortToast(translation.format("magic_toast", "count" to (5 - tapCount.intValue).toString()))
}
if (tapCount.intValue >= 5) { tapCount.intValue = 0; routes.retroGame.navigate() }
}
)
Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = PurrfectPalette.textSecondary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = Color(0xFFD9D3FF), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
Text(text = translation["about_lead_developers_title"] ?: "Lead Developers", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White, modifier = Modifier.padding(top = 10.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
@@ -929,7 +944,7 @@ object LegacyTheme : ThemeContract {
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(text = translation["about_story_title"] ?: "Our Story", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(text = aboutStory, fontSize = 14.sp, color = PurrfectPalette.textSecondary, lineHeight = 20.sp)
Text(text = aboutStory, fontSize = 14.sp, color = Color(0xFFD9D3FF), lineHeight = 20.sp)
}
}
@@ -967,6 +982,53 @@ object LegacyTheme : ThemeContract {
}
}
@Composable
internal fun HomeAbout.DeveloperCard(
name: String,
imageRes: Int,
avenirNext: FontFamily,
modifier: Modifier = Modifier
) {
val tapSource = remember { MutableInteractionSource() }
Surface(
modifier = modifier.scaleOnPress(tapSource),
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
modifier = Modifier.size(64.dp),
shape = CircleShape,
color = Color.Transparent,
border = BorderStroke(2.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)))
) {
Image(
painter = painterResource(id = imageRes),
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(CircleShape)
)
}
PurrfectMarqueeText(
text = name,
color = Color.White,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext
)
)
}
}
}
@Composable override fun HomeLogs.LogsScreen(nav: NavBackStackEntry) {
val coroutineScope = rememberCoroutineScope()
val composeContext = LocalContext.current
@@ -1144,7 +1206,7 @@ object LegacyTheme : ThemeContract {
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
@@ -1225,12 +1287,10 @@ object LegacyTheme : ThemeContract {
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(Unit) {
fetchActiveTasks(this)
}
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
fetchActiveTasks(scope)
while (true) {
fetchActiveTasks(this)
fetchNewRecentTasks()
delay(1000)
}
}
@@ -1301,7 +1361,7 @@ object LegacyTheme : ThemeContract {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
TopBarActionButton(
Surface(
onClick = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -1312,9 +1372,19 @@ object LegacyTheme : ThemeContract {
.map { it.first to it.second!! }
)
},
icon = Icons.Filled.Merge,
text = translation["merge_button"]
)
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(translation["merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 12.sp)
}
}
}
}
Surface(
@@ -1380,13 +1450,6 @@ object LegacyTheme : ThemeContract {
TaskCard(modifier = Modifier.fillMaxWidth(), task)
}
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { listState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
}
}
}
}
}
@@ -1407,7 +1470,6 @@ object LegacyTheme : ThemeContract {
message = messageText ?: "",
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
tasksTranslation = translation,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false

View File

@@ -362,7 +362,7 @@
"search_button": "Search",
"search_results_count": "{count} messages",
"clear_history": "Clear search history",
"subtitle": "Search and manage features"
"subtitle": "Explore and manage premium features"
},
"bypass_status": {
"active": "PurrAura Active",
@@ -392,7 +392,7 @@
"friends_empty_title": "No friends added yet",
"groups_empty_title": "No groups synced yet",
"streaks_expiration_short": "{hours}h",
"social_tagline": "Manage scopes, streaks, and previews",
"social_tagline": "Manage friends, groups, and streaks",
"social_empty_hint": "Tap the + button to sync friends or groups.",
"messaging_preview": {
"bridge_connection_failed": "Failed to connect to bridge. Make sure Snapchat is running in the background",

View File

@@ -0,0 +1,121 @@
package me.eternal.purrfectsnap.common.ui.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
@Composable
fun AphelionFriendMutationToast(
icon: ImageVector,
text: String,
bitmojiUrl: String?,
onDismiss: () -> Unit
) {
var visible by remember { mutableStateOf(false) }
var bitmojiBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(bitmojiUrl) {
if (bitmojiUrl != null) {
runCatching {
me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver.downloadMedia(bitmojiUrl) { inputStream, _ ->
bitmojiBitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
}
}
}
}
LaunchedEffect(Unit) {
visible = true
delay(5000)
visible = false
delay(500)
onDismiss()
}
val progress by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessLow),
label = "progress"
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 16.dp),
contentAlignment = Alignment.TopCenter
) {
Box(
modifier = Modifier.graphicsLayer {
translationY = -100f * (1f - progress)
alpha = progress
scaleX = 0.9f + (0.1f * progress)
scaleY = 0.9f + (0.1f * progress)
}
) {
Surface(
modifier = Modifier
.widthIn(max = 340.dp)
.shadow(20.dp, RoundedCornerShape(28.dp)),
shape = RoundedCornerShape(28.dp),
color = Color(0xE61B152E),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.15f))
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(42.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
if (bitmojiBitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmojiBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
}
Text(
text = text,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
lineHeight = 18.sp,
modifier = Modifier.weight(1f)
)
}
}
}
}
}

View File

@@ -49,6 +49,8 @@ dependencies {
implementation(libs.androidx.material.ripple)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.material3)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.hiddenapibypass)
implementation(libs.colorpicker.compose)
}

View File

@@ -5,8 +5,10 @@ import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.os.Build
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cancel
import androidx.compose.runtime.Composable
import java.lang.reflect.Method
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -32,12 +34,14 @@ import me.eternal.purrfectsnap.core.data.SnapClassCache
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
import me.eternal.purrfectsnap.core.ui.InAppOverlay
import me.eternal.purrfectsnap.core.ui.CustomComposable
import me.eternal.purrfectsnap.core.util.LSPatchUpdater
import me.eternal.purrfectsnap.core.util.hook.HookAdapter
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.mapper.impl.PlatformClientAttestationMapper
import me.eternal.purrfectsnap.common.ui.components.AphelionFriendMutationToast
import kotlin.reflect.KClass
import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis
@@ -216,6 +220,26 @@ class PurrfectSnap {
log.verbose("Initializing features...")
runCatching {
features.init()
// Wire up the premium friend mutation toast provider
features.get(me.eternal.purrfectsnap.core.features.impl.FriendMutationObserver::class)?.let { observer ->
observer.aphelionToastProvider = { icon, text, bitmojiUrl, onDismiss ->
lateinit var composable: CustomComposable
composable = @Composable {
AphelionFriendMutationToast(
icon = icon,
text = text,
bitmojiUrl = bitmojiUrl,
onDismiss = {
inAppOverlay.removeCustomComposable(composable)
onDismiss()
}
)
}
inAppOverlay.addCustomComposable(composable)
}
}
log.verbose("Features initialized successfully")
}.onFailure { throwable ->
log.error("Failed to initialize features", throwable)

View File

@@ -4,13 +4,13 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
import com.google.gson.JsonObject
import me.eternal.purrfectsnap.common.data.FriendLinkType
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.EvictingMap
import java.io.InputStreamReader
import java.util.Calendar
@@ -23,25 +23,28 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
private val channelId by lazy {
"friend_mutation_observer".also {
notificationManager.createNotificationChannel(
NotificationChannel(
it,
translation["notification_channel_name"],
NotificationManager.IMPORTANCE_HIGH
)
NotificationChannel(it, translation["notification_channel_name"], NotificationManager.IMPORTANCE_HIGH)
)
}
}
fun getFriendAddSource(userId: String): String? {
return addSourceCache[userId]
}
// Injected from app layer — keeps core free of ui.* imports
var aphelionToastProvider: ((
icon: ImageVector,
text: String,
bitmojiUrl: String?,
onDismiss: () -> Unit
) -> Unit)? = null
fun getFriendAddSource(userId: String): String? = addSourceCache[userId]
private fun sendMutationNotification(icon: ImageVector, contentText: String, friendInfo: FriendInfo? = null) {
val currentTheme = context.config.global.uiSettings.managerTheme.get()
val isAphelion = currentTheme == "APHELION"
private fun sendWarnNotification(
contentText: String
) {
notificationManager.notify(System.nanoTime().toInt(),
Notification.Builder(context.androidContext, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(translation["notification_channel_name"])
.setContentText(contentText)
.setShowWhen(true)
@@ -49,11 +52,21 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
.build()
)
context.inAppOverlay.showStatusToast(
Icons.Default.WarningAmber,
contentText,
durationMs = 7000
)
val provider = aphelionToastProvider
if (isAphelion && provider != null) {
val bitmojiUrl = friendInfo?.let {
me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie.getBitmojiSelfie(
it.bitmojiSelfieId,
it.bitmojiAvatarId,
me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
)
}
provider(icon, contentText, bitmojiUrl) {
// onDismiss handled inside the provider lambda in app layer
}
} else {
context.inAppOverlay.showStatusToast(icon, contentText, durationMs = 7000)
}
}
private fun formatUsername(friendInfo: FriendInfo): String {
@@ -65,44 +78,34 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
private fun prettyPrintBirthday(month: Int, day: Int): String {
val calendar = Calendar.getInstance()
calendar[Calendar.MONTH] = month
return calendar.getDisplayName(
Calendar.MONTH,
Calendar.LONG,
context.translation.loadedLocale
)?.toString() + " " + day
return calendar.getDisplayName(Calendar.MONTH, Calendar.LONG, context.translation.loadedLocale)?.toString() + " " + day
}
override fun init() {
val config by context.config.messaging.friendMutationNotifier
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
if (!event.url.contains("ami/friends")) return@subscribe
event.onSuccess { buffer ->
runCatching {
val jsonObject = context.gson.fromJson(InputStreamReader(buffer?.inputStream() ?: return@onSuccess, Charsets.UTF_8), JsonObject::class.java)
jsonObject.getAsJsonArray("added_friends").map { it.asJsonObject }.forEach { friend ->
jsonObject.getAsJsonArray("added_friends")?.map { it.asJsonObject }?.forEach { friend ->
val userId = friend.get("user_id").asString
(friend.get("add_source")?.asString?.takeIf {
it.isNotBlank()
} ?: friend.get("add_source_type")?.asString?.takeIf {
it.isNotBlank()
})?.let {
(friend.get("add_source")?.asString?.takeIf { it.isNotBlank() }
?: friend.get("add_source_type")?.asString?.takeIf { it.isNotBlank() })?.let {
addSourceCache[userId] = it
}
}
if (config.isEmpty()) return@runCatching
jsonObject.getAsJsonArray("friends").map { it.asJsonObject }.forEach { friend ->
jsonObject.getAsJsonArray("friends")?.map { it.asJsonObject }?.forEach { friend ->
runCatching {
val userId = friend.get("user_id")?.asString
val userId = friend.get("user_id")?.asString ?: return@forEach
if (userId == context.database.myUserId) return@forEach
val databaseFriend = context.database.getFriendInfo(userId ?: return@forEach) ?: return@forEach
val databaseFriend = context.database.getFriendInfo(userId) ?: return@forEach
if (FriendLinkType.fromValue(databaseFriend.friendLinkType) != FriendLinkType.MUTUAL) return@forEach
if (config.contains("remove_friend") && friend.get("direction")?.asString == "OUTGOING" && !friend.has("fidelius_info")) {
sendWarnNotification(translation.format("friend_removed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.PersonRemove, translation.format("friend_removed", "username" to formatUsername(databaseFriend)), databaseFriend)
return@forEach
}
@@ -111,47 +114,38 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
((it shr 32).toInt()).toString().padStart(2, '0') + "-" + (it.toInt()).toString().padStart(2, '0')
} != friend.get("birthday")?.asString
) {
val oldBirthday = databaseFriend.birthday.takeIf { it != 0L }?.let {
prettyPrintBirthday((it shr 32).toInt() - 1, it.toInt())
}
val oldBirthday = databaseFriend.birthday.takeIf { it != 0L }?.let { prettyPrintBirthday((it shr 32).toInt() - 1, it.toInt()) }
if (!friend.has("birthday")) {
sendWarnNotification(translation.format("birthday_removed", "username" to formatUsername(databaseFriend), "birthday" to oldBirthday.orEmpty()))
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_removed", "username" to formatUsername(databaseFriend), "birthday" to oldBirthday.orEmpty()), databaseFriend)
} else {
val newBirthday = friend.get("birthday")?.asString?.split("-")?.let {
prettyPrintBirthday(it[0].toInt() - 1, it[1].toInt())
}
val newBirthday = friend.get("birthday")?.asString?.split("-")?.let { prettyPrintBirthday(it[0].toInt() - 1, it[1].toInt()) }
if (oldBirthday == null) {
sendWarnNotification(translation.format("birthday_added", "username" to formatUsername(databaseFriend), "birthday" to newBirthday.orEmpty()))
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_added", "username" to formatUsername(databaseFriend), "birthday" to newBirthday.orEmpty()), databaseFriend)
} else {
sendWarnNotification(translation.format("birthday_changed", "username" to formatUsername(databaseFriend), "oldBirthday" to oldBirthday, "newBirthday" to newBirthday.orEmpty()))
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_changed", "username" to formatUsername(databaseFriend), "oldBirthday" to oldBirthday, "newBirthday" to newBirthday.orEmpty()), databaseFriend)
}
}
}
if (config.contains("bitmoji_avatar_changes") && databaseFriend.bitmojiAvatarId != friend.get("bitmoji_avatar_id")?.asString) {
sendWarnNotification(translation.format("bitmoji_avatar_changed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.Face, translation.format("bitmoji_avatar_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
}
if (config.contains("bitmoji_selfie_changes") && databaseFriend.bitmojiSelfieId != friend.get("bitmoji_selfie_id")?.asString) {
sendWarnNotification(translation.format("bitmoji_selfie_changed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.Face, translation.format("bitmoji_selfie_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
}
if (config.contains("bitmoji_background_changes") && databaseFriend.bitmojiBackgroundId != friend.get("bitmoji_background_id")?.asString) {
sendWarnNotification(translation.format("bitmoji_background_changed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.Image, translation.format("bitmoji_background_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
}
if (config.contains("bitmoji_scene_changes") && databaseFriend.bitmojiSceneId != friend.get("bitmoji_scene_id")?.asString) {
sendWarnNotification(translation.format("bitmoji_scene_changed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.Landscape, translation.format("bitmoji_scene_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
}
}.onFailure {
context.log.error("Failed to process friend", it)
}
}.onFailure { context.log.error("Failed to process friend", it) }
}
}.onFailure {
context.log.error("Failed to process friends", it)
}
}.onFailure { context.log.error("Failed to process friends", it) }
}
}
}
}
}

View File

@@ -31,17 +31,18 @@ workmanager = "2.10.4"
fetch = "3.4.1"
yukihookapi = "1.3.1"
kavaref = "1.0.2"
constraintlayout-compose = "1.1.0"
constraintlayout = "2.2.1"
[libraries]
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintlayout-compose" }
accompanist-navigation-animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanist" }
fetch = { group = "com.github.tonyofrancis.Fetch", name = "fetch2", version.ref = "fetch" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidx-documentfile" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" }