mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-12 13:17:42 +02:00
feat(experimental): in-app overlay
This commit is contained in:
@ -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() }
|
||||
|
||||
|
@ -165,6 +165,7 @@ class SnapEnhance {
|
||||
measureTimeMillis {
|
||||
with(appContext) {
|
||||
features.onActivityCreate()
|
||||
inAppOverlay.onActivityCreate(mainActivity!!)
|
||||
scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", mainActivity!!) }
|
||||
}
|
||||
}.also { time ->
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -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
|
||||
))
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user