UI Fixes for Aphelion and Optimizations
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -777,3 +777,6 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -900,3 +900,6 @@ class ScriptingRootSection : Routes.Route() {
|
||||
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -569,3 +569,6 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user