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:
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