commit 342bc7c68bc0c99de11ba0029367d3bf04662697
parent 30e96f1e7120ba03769b48235f3d62552e390bf3
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sun,  3 Mar 2024 19:40:53 +0100

feat(experimental): in-app overlay

Diffstat:
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt | 3++-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt | 29+++++++++++++++++++++++++----
Acore/src/main/kotlin/me/rhunk/snapenhance/core/ui/InAppOverlay.kt | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 230 insertions(+), 5 deletions(-)

diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -1,7 +1,6 @@ package me.rhunk.snapenhance.core import android.app.Activity -import android.content.ClipData import android.content.Context import android.content.Intent import android.content.res.Resources @@ -30,6 +29,7 @@ import me.rhunk.snapenhance.core.manager.impl.FeatureManager import me.rhunk.snapenhance.core.messaging.CoreMessagingBridge import me.rhunk.snapenhance.core.messaging.MessageSender import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime +import me.rhunk.snapenhance.core.ui.InAppOverlay import me.rhunk.snapenhance.core.util.media.HttpServer import me.rhunk.snapenhance.nativelib.NativeConfig import me.rhunk.snapenhance.nativelib.NativeLib @@ -64,6 +64,7 @@ class ModContext( val native = NativeLib() val scriptRuntime by lazy { CoreScriptRuntime(this, log) } val messagingBridge = CoreMessagingBridge(this) + val inAppOverlay = InAppOverlay() val isDeveloper by lazy { config.scripting.developerMode.get() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -165,6 +165,7 @@ class SnapEnhance { measureTimeMillis { with(appContext) { features.onActivityCreate() + inAppOverlay.onActivityCreate(mainActivity!!) scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", mainActivity!!) } } }.also { time -> diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -10,6 +10,11 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Error +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Warning import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking @@ -112,23 +117,39 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp override fun onSuccess(outputFile: String) { if (!downloadLogging.contains("success")) return context.log.verbose("onSuccess: outputFile=$outputFile") - context.shortToast(translations.format("saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/"))) + context.inAppOverlay.showStatusToast( + icon = Icons.Outlined.CheckCircle, + text = translations.format("saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/")), + ) } override fun onProgress(message: String) { if (!downloadLogging.contains("progress")) return context.log.verbose("onProgress: message=$message") - context.shortToast(message) + context.inAppOverlay.showStatusToast( + icon = Icons.Outlined.Info, + text = message, + ) + // context.shortToast(message) } override fun onFailure(message: String, throwable: String?) { if (!downloadLogging.contains("failure")) return context.log.verbose("onFailure: message=$message, throwable=$throwable") throwable?.let { - context.longToast((message + it.takeIf { it.isNotEmpty() }.orEmpty())) + context.inAppOverlay.showStatusToast( + icon = Icons.Outlined.Error, + text = message + it.takeIf { it.isNotEmpty() }.orEmpty(), + ) + // context.longToast((message + it.takeIf { it.isNotEmpty() }.orEmpty())) return } - context.shortToast(message) + + context.inAppOverlay.showStatusToast( + icon = Icons.Outlined.Warning, + text = message, + ) + // context.shortToast(message) } } ) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/InAppOverlay.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/InAppOverlay.kt @@ -0,0 +1,201 @@ +package me.rhunk.snapenhance.core.ui + +import android.app.Activity +import android.widget.FrameLayout +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +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.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import me.rhunk.snapenhance.common.ui.AppMaterialTheme +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.core.util.ktx.isDarkTheme +import kotlin.math.roundToInt + +class InAppOverlay { + inner class Toast( + val composable: @Composable Toast.() -> Unit, + val durationMs: Int + ) { + var shown by mutableStateOf(false) + var visible by mutableStateOf(false) + } + + private val toasts = mutableStateListOf<Toast>() + + @OptIn(ExperimentalFoundationApi::class) + @Composable + private fun OverlayContent() { + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding(), + ) { + toasts.forEach { toast -> + val animation by animateFloatAsState( + targetValue = if (toast.visible) 1f else 0f, + animationSpec = if (toast.visible) tween(durationMillis = 150) else tween(durationMillis = 300), + label = "toast" + ) + + LaunchedEffect(toast) { + toast.visible = true + delay(toast.durationMs.toLong()) + toast.visible = false + delay(1000) + toast.shown = true + synchronized(toasts) { + if (toasts.isNotEmpty() && toasts.all { it.shown }) toasts.clear() + } + } + + val deviceWidth = LocalContext.current.resources.displayMetrics.widthPixels + val draggableState = remember { + AnchoredDraggableState( + initialValue = 0, + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { deviceWidth / 2f }, + animationSpec = tween(), + confirmValueChange = { + toast.visible = false + true + } + ).apply { + updateAnchors( + DraggableAnchors { + -1 at -deviceWidth.toFloat() + 0 at 0f + 1 at deviceWidth.toFloat() + } + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .anchoredDraggable(draggableState, Orientation.Horizontal) + .offset { IntOffset(draggableState.offset.roundToInt(), 0) } + .graphicsLayer { + alpha = animation + translationY = -100.dp.toPx() * (1 - animation) + } + ) { + if (animation > 0.01f) { + toast.composable(toast) + } + } + } + } + } + + fun onActivityCreate(activity: Activity) { + val root = activity.findViewById<FrameLayout>(android.R.id.content) + root.post { + root.addView(createComposeView(activity) { + AppMaterialTheme(isDarkTheme = remember { activity.isDarkTheme() }) { + OverlayContent() + } + }.apply { + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + }) + } + } + + @Composable + private fun DurationProgress( + duration: Int, + modifier: Modifier = Modifier + ) { + val progress = remember { Animatable(1f) } + + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = duration, easing = LinearEasing) + ) + } + + LinearProgressIndicator( + progress = { progress.value }, + modifier = modifier + ) + } + + fun showStatusToast( + icon: ImageVector, + text: String, + durationMs: Int = 2000, + showDuration: Boolean = true, + ) { + showToast( + icon = { Icon(icon, contentDescription = "icon", modifier = Modifier.size(32.dp)) }, + text = { + Text(text, modifier = Modifier.fillMaxWidth(), maxLines = 2, overflow = TextOverflow.Ellipsis) + }, + durationMs = durationMs, + showDuration = showDuration + ) + } + + fun showToast( + icon: @Composable () -> Unit = { + Icon(Icons.Outlined.Warning, contentDescription = "icon", modifier = Modifier.size(32.dp)) + }, + text: @Composable () -> Unit = {}, + durationMs: Int = 3000, + showDuration: Boolean = true, + ) { + toasts.add(Toast( + composable = { + Card( + modifier = Modifier + .padding(16.dp) + .shadow(8.dp, RoundedCornerShape(8.dp)) + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + icon() + text() + } + if (showDuration) { + DurationProgress(duration = durationMs, modifier = Modifier.fillMaxWidth()) + } + } + }, + durationMs = durationMs + )) + } +}+ \ No newline at end of file