Compare commits

...

16 Commits

Author SHA1 Message Date
zhx
071ab9ffed 增加mp4的解析展示 2026-05-18 19:04:56 +08:00
zhx
f03b6c605f fix: show full profile card preview 2026-05-18 14:18:14 +08:00
zhx
b5cb66997b fix: show store previews fullscreen 2026-05-18 14:08:16 +08:00
zhx
a800a0f941 fix: preview store item effects 2026-05-18 13:23:38 +08:00
zhx
2684fd4673 fix: reserve profile badge header space 2026-05-18 13:01:02 +08:00
zhx
9294f2a08f fix: tune chat badge sizes 2026-05-18 12:49:25 +08:00
zhx
8e3798e85c fix: center room card long badge area 2026-05-18 12:45:57 +08:00
zhx
27c89a9f9e fix: show long badges in room chat items 2026-05-18 12:39:03 +08:00
zhx
5be1f05899 fix: expand room short badge area 2026-05-18 12:36:47 +08:00
zhx
79ec634ba0 fix: restore room short badge size 2026-05-18 12:30:10 +08:00
zhx
5a44ec0c6d fix: keep empty scoped badge lists authoritative 2026-05-18 12:10:07 +08:00
zhx
c2b5ef9e16 fix: wrap long badge displays 2026-05-18 12:01:26 +08:00
zhx
d5653afa4f Merge branch 'feature' into test 2026-05-18 11:29:29 +08:00
zhx
660f712ae6 fix: wrap room card long badge inline 2026-05-18 11:18:54 +08:00
zhx
d009f9cc01 fix: show long badges on room user card 2026-05-18 11:13:36 +08:00
zhx
9f012fdb27 fix: filter long badges from short badge strips 2026-05-18 11:09:55 +08:00
33 changed files with 3178 additions and 1210 deletions

View File

@ -88,6 +88,7 @@ android {
dependencies {
implementation("androidx.multidex:multidex:2.0.1")
implementation("com.android.installreferrer:installreferrer:2.2")
implementation("androidx.media3:media3-exoplayer:1.9.2")
// implementation(platform("com.google.firebase:firebase-bom:34.0.0"))
// implementation("com.google.firebase:firebase-auth")
// implementation("com.google.firebase:firebase-core:16.0.8")

View File

@ -0,0 +1,341 @@
package com.org.yumiparty
import android.content.Context
import android.graphics.PixelFormat
import android.graphics.SurfaceTexture
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.GLSurfaceView
import android.opengl.Matrix
import android.view.Surface
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
class GiftMp4GlVideoView(context: Context) :
GLSurfaceView(context),
GLSurfaceView.Renderer,
SurfaceTexture.OnFrameAvailableListener {
var onSurfaceReady: ((Surface) -> Unit)? = null
private val texMatrix = FloatArray(16)
private val vertexBuffer = floatBufferOf(VERTICES)
private val positionScale = floatArrayOf(1f, 1f)
private var program = 0
private var oesTexture = 0
private var surfaceTexture: SurfaceTexture? = null
private var surface: Surface? = null
private var positionHandle = 0
private var texCoordHandle = 0
private var texMatrixHandle = 0
private var textureHandle = 0
private var hasAlphaMaskHandle = 0
private var useTextureMatrixHandle = 0
private var flipYHandle = 0
private var positionScaleHandle = 0
private var colorRectHandle = 0
private var alphaRectHandle = 0
private var viewWidth = 1
private var viewHeight = 1
private var contentAspectRatio = 1f
private var hasAlphaMask = true
private var useTextureMatrix = true
private var flipY = true
private val colorRect = floatArrayOf(0.5f, 0f, 0.5f, 1f)
private val alphaRect = floatArrayOf(0f, 0f, 0.5f, 1f)
init {
setEGLContextClientVersion(2)
setEGLConfigChooser(8, 8, 8, 8, 16, 0)
holder.setFormat(PixelFormat.TRANSLUCENT)
setZOrderMediaOverlay(true)
preserveEGLContextOnPause = true
setRenderer(this)
renderMode = RENDERMODE_WHEN_DIRTY
Matrix.setIdentityM(texMatrix, 0)
}
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
releaseSurfaceObjects()
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
positionHandle = GLES20.glGetAttribLocation(program, "aPosition")
texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord")
texMatrixHandle = GLES20.glGetUniformLocation(program, "uTexMatrix")
textureHandle = GLES20.glGetUniformLocation(program, "uTexture")
hasAlphaMaskHandle = GLES20.glGetUniformLocation(program, "uHasAlphaMask")
useTextureMatrixHandle = GLES20.glGetUniformLocation(program, "uUseTextureMatrix")
flipYHandle = GLES20.glGetUniformLocation(program, "uFlipY")
positionScaleHandle = GLES20.glGetUniformLocation(program, "uPositionScale")
colorRectHandle = GLES20.glGetUniformLocation(program, "uColorRect")
alphaRectHandle = GLES20.glGetUniformLocation(program, "uAlphaRect")
oesTexture = createExternalTexture()
surfaceTexture =
SurfaceTexture(oesTexture).also {
it.setOnFrameAvailableListener(this)
}
surface =
Surface(surfaceTexture).also {
post { onSurfaceReady?.invoke(it) }
}
GLES20.glClearColor(0f, 0f, 0f, 0f)
GLES20.glEnable(GLES20.GL_BLEND)
GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA)
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
viewWidth = maxOf(1, width)
viewHeight = maxOf(1, height)
updatePositionScale()
GLES20.glViewport(0, 0, width, height)
}
override fun onDrawFrame(gl: GL10?) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
val texture = surfaceTexture ?: return
texture.updateTexImage()
texture.getTransformMatrix(texMatrix)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTexture)
GLES20.glUniform1i(textureHandle, 0)
GLES20.glUniform1i(hasAlphaMaskHandle, if (hasAlphaMask) 1 else 0)
GLES20.glUniform1i(useTextureMatrixHandle, if (useTextureMatrix) 1 else 0)
GLES20.glUniform1i(flipYHandle, if (flipY) 1 else 0)
GLES20.glUniform2fv(positionScaleHandle, 1, positionScale, 0)
GLES20.glUniform4fv(colorRectHandle, 1, colorRect, 0)
GLES20.glUniform4fv(alphaRectHandle, 1, alphaRect, 0)
GLES20.glUniformMatrix4fv(texMatrixHandle, 1, false, texMatrix, 0)
vertexBuffer.position(0)
GLES20.glEnableVertexAttribArray(positionHandle)
GLES20.glVertexAttribPointer(
positionHandle,
3,
GLES20.GL_FLOAT,
false,
STRIDE_BYTES,
vertexBuffer,
)
vertexBuffer.position(3)
GLES20.glEnableVertexAttribArray(texCoordHandle)
GLES20.glVertexAttribPointer(
texCoordHandle,
2,
GLES20.GL_FLOAT,
false,
STRIDE_BYTES,
vertexBuffer,
)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(positionHandle)
GLES20.glDisableVertexAttribArray(texCoordHandle)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0)
}
override fun onFrameAvailable(texture: SurfaceTexture?) {
requestRender()
}
fun setSourceLayout(
alphaEnabled: Boolean,
textureMatrixEnabled: Boolean,
flipYEnabled: Boolean,
aspectRatio: Float,
colorSourceRect: FloatArray,
alphaSourceRect: FloatArray,
) {
queueEvent {
hasAlphaMask = alphaEnabled
useTextureMatrix = textureMatrixEnabled
flipY = flipYEnabled
contentAspectRatio = aspectRatio.coerceAtLeast(0.01f)
colorSourceRect.copyInto(colorRect, endIndex = minOf(colorSourceRect.size, colorRect.size))
alphaSourceRect.copyInto(alphaRect, endIndex = minOf(alphaSourceRect.size, alphaRect.size))
updatePositionScale()
requestRender()
}
}
fun release() {
queueEvent {
releaseSurfaceObjects()
if (program != 0) {
GLES20.glDeleteProgram(program)
program = 0
}
}
}
private fun updatePositionScale() {
val viewAspect = viewWidth.toFloat() / viewHeight.toFloat()
if (viewAspect > contentAspectRatio) {
positionScale[0] = contentAspectRatio / viewAspect
positionScale[1] = 1f
} else {
positionScale[0] = 1f
positionScale[1] = viewAspect / contentAspectRatio
}
}
private fun releaseSurfaceObjects() {
surface?.release()
surface = null
surfaceTexture?.release()
surfaceTexture = null
if (oesTexture != 0) {
val textures = intArrayOf(oesTexture)
GLES20.glDeleteTextures(1, textures, 0)
oesTexture = 0
}
}
private fun createExternalTexture(): Int {
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0])
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MIN_FILTER,
GLES20.GL_LINEAR,
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MAG_FILTER,
GLES20.GL_LINEAR,
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_S,
GLES20.GL_CLAMP_TO_EDGE,
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_T,
GLES20.GL_CLAMP_TO_EDGE,
)
return textures[0]
}
private fun createProgram(vertexSource: String, fragmentSource: String): Int {
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
val glProgram = GLES20.glCreateProgram()
GLES20.glAttachShader(glProgram, vertexShader)
GLES20.glAttachShader(glProgram, fragmentShader)
GLES20.glLinkProgram(glProgram)
val status = IntArray(1)
GLES20.glGetProgramiv(glProgram, GLES20.GL_LINK_STATUS, status, 0)
if (status[0] == 0) {
val log = GLES20.glGetProgramInfoLog(glProgram)
GLES20.glDeleteProgram(glProgram)
throw IllegalStateException("OpenGL program link failed: $log")
}
GLES20.glDeleteShader(vertexShader)
GLES20.glDeleteShader(fragmentShader)
return glProgram
}
private fun compileShader(type: Int, source: String): Int {
val shader = GLES20.glCreateShader(type)
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
val status = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, status, 0)
if (status[0] == 0) {
val log = GLES20.glGetShaderInfoLog(shader)
GLES20.glDeleteShader(shader)
throw IllegalStateException("OpenGL shader compile failed: $log")
}
return shader
}
companion object {
private const val FLOAT_BYTES = 4
private const val STRIDE_BYTES = 5 * FLOAT_BYTES
private val VERTICES =
floatArrayOf(
-1f, -1f, 0f, 0f, 1f,
1f, -1f, 0f, 1f, 1f,
-1f, 1f, 0f, 0f, 0f,
1f, 1f, 0f, 1f, 0f,
)
private val VERTEX_SHADER =
"""
attribute vec4 aPosition;
attribute vec2 aTexCoord;
uniform vec2 uPositionScale;
varying vec2 vTexCoord;
void main() {
gl_Position = vec4(aPosition.x * uPositionScale.x, aPosition.y * uPositionScale.y, 0.0, 1.0);
vTexCoord = aTexCoord;
}
""".trimIndent()
private val FRAGMENT_SHADER =
"""
#extension GL_OES_EGL_image_external : require
precision mediump float;
uniform samplerExternalOES uTexture;
uniform mat4 uTexMatrix;
uniform int uHasAlphaMask;
uniform int uUseTextureMatrix;
uniform int uFlipY;
uniform vec4 uColorRect;
uniform vec4 uAlphaRect;
varying vec2 vTexCoord;
vec2 mapTexture(vec2 uv) {
if (uUseTextureMatrix == 0) {
return uv;
}
return (uTexMatrix * vec4(uv, 0.0, 1.0)).xy;
}
void main() {
float sourceY = uFlipY == 1 ? 1.0 - vTexCoord.y : vTexCoord.y;
vec2 colorUv = vec2(
uColorRect.x + vTexCoord.x * uColorRect.z,
uColorRect.y + sourceY * uColorRect.w
);
vec2 mappedColor = mapTexture(colorUv);
vec4 color = texture2D(uTexture, mappedColor);
if (uHasAlphaMask == 0) {
float alpha = clamp(color.a, 0.0, 1.0);
gl_FragColor = vec4(color.rgb * alpha, alpha);
return;
}
vec2 alphaUv = vec2(
uAlphaRect.x + vTexCoord.x * uAlphaRect.z,
uAlphaRect.y + sourceY * uAlphaRect.w
);
vec2 mappedAlpha = mapTexture(alphaUv);
vec3 mask = texture2D(uTexture, mappedAlpha).rgb;
float alpha = clamp(dot(mask, vec3(0.299, 0.587, 0.114)), 0.0, 1.0);
gl_FragColor = vec4(color.rgb * alpha, alpha);
}
""".trimIndent()
private fun floatBufferOf(values: FloatArray): FloatBuffer {
return ByteBuffer
.allocateDirect(values.size * FLOAT_BYTES)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply {
put(values)
position(0)
}
}
}
}

View File

@ -0,0 +1,815 @@
package com.org.yumiparty
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.Surface
import android.view.View
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import io.flutter.FlutterInjector
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.platform.PlatformView
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
class GiftMp4VideoPlatformView(
context: Context,
private val viewId: Int,
params: Map<*, *>,
messenger: io.flutter.plugin.common.BinaryMessenger,
) : PlatformView {
private val mainHandler = Handler(Looper.getMainLooper())
private val events = MethodChannel(messenger, CHANNEL_NAME)
private val glView = GiftMp4GlVideoView(context)
private val player = ExoPlayer.Builder(context).build()
private val token = (params["token"] as? Number)?.toInt() ?: 0
private var released = false
private var prepared = false
private var started = false
private var playerSurface: Surface? = null
init {
val path = params["path"] as? String ?: ""
val sourceType = params["sourceType"] as? String ?: "file"
val muted = params["muted"] as? Boolean ?: false
val source = GiftMp4Source(context, path, sourceType)
Log.d(
"GiftMp4Video",
"create viewId=$viewId token=$token sourceType=$sourceType path=$path",
)
val sourceLayout = resolveSourceLayout(source)
registry[viewId] = this
glView.setSourceLayout(
sourceLayout.hasAlphaMask,
sourceLayout.useTextureMatrix,
sourceLayout.flipY,
sourceLayout.contentAspectRatio,
sourceLayout.colorRect.toFloatArray(),
sourceLayout.alphaRect.toFloatArray(),
)
player.volume = if (muted) 0f else 1f
player.repeatMode = Player.REPEAT_MODE_OFF
player.addListener(
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
if (!started) {
started = true
Log.d(
"GiftMp4Video",
"started viewId=$viewId token=$token",
)
postEvent("started")
}
}
Player.STATE_ENDED -> {
Log.d(
"GiftMp4Video",
"completed viewId=$viewId token=$token",
)
postEvent("completed")
}
}
}
override fun onPlayerError(error: PlaybackException) {
Log.e(
"GiftMp4Video",
"failed viewId=$viewId token=$token code=${error.errorCode}",
error,
)
postEvent(
"failed",
mapOf(
"code" to error.errorCode,
"message" to error.message,
),
)
}
},
)
glView.onSurfaceReady = onSurfaceReady@{ surface ->
if (released) {
surface.release()
return@onSurfaceReady
}
playerSurface?.release()
playerSurface = surface
player.setVideoSurface(surface)
if (!prepared) {
prepared = true
player.setMediaItem(source.toMediaItem())
player.prepare()
player.seekTo(0)
player.play()
}
}
}
override fun getView(): View = glView
fun setMute(muted: Boolean) {
player.volume = if (muted) 0f else 1f
}
fun stop() {
player.stop()
}
override fun dispose() {
released = true
registry.remove(viewId)
glView.onSurfaceReady = null
player.clearVideoSurface()
player.release()
playerSurface?.release()
playerSurface = null
glView.release()
}
private fun postEvent(
method: String,
extra: Map<String, Any?> = emptyMap(),
) {
mainHandler.post {
events.invokeMethod(
method,
mapOf("viewId" to viewId, "token" to token) + extra,
)
}
}
private class GiftMp4Source(
private val context: Context,
val path: String,
sourceType: String,
) {
val type: SourceType =
if (sourceType == "asset") SourceType.ASSET else SourceType.FILE
val assetKey: String? =
if (type == SourceType.ASSET) {
FlutterInjector
.instance()
.flutterLoader()
.getLookupKeyForAsset(path)
} else {
null
}
val cacheKey: String = "${type.name}:${assetKey ?: path}"
fun toMediaItem(): MediaItem {
return if (type == SourceType.ASSET) {
MediaItem.fromUri(Uri.parse("asset:///$assetKey"))
} else {
MediaItem.fromUri(Uri.fromFile(File(path)))
}
}
fun readBytes(): ByteArray {
return if (type == SourceType.ASSET) {
context.assets.open(assetKey ?: path).use { it.readBytes() }
} else {
File(path).readBytes()
}
}
fun newMetadataRetriever(): MediaMetadataRetriever {
val retriever = MediaMetadataRetriever()
if (type == SourceType.ASSET) {
context.assets.openFd(assetKey ?: path).use { descriptor ->
retriever.setDataSource(
descriptor.fileDescriptor,
descriptor.startOffset,
descriptor.length,
)
}
} else {
retriever.setDataSource(path)
}
return retriever
}
}
private enum class SourceType { ASSET, FILE }
private data class VideoSize(
val width: Int,
val height: Int,
) {
val aspectRatio: Float
get() = if (height <= 0) 1f else width.toFloat() / height.toFloat()
}
private data class SourceRect(
val x: Float,
val y: Float,
val width: Float,
val height: Float,
) {
fun toFloatArray(): FloatArray = floatArrayOf(x, y, width, height)
}
private data class SourceLayout(
val kind: String,
val hasAlphaMask: Boolean,
val useTextureMatrix: Boolean,
val flipY: Boolean,
val contentAspectRatio: Float,
val colorRect: SourceRect,
val alphaRect: SourceRect,
)
private data class HalfStats(
val saturation: Double,
val grayError: Double,
)
private enum class SplitOrientation { HORIZONTAL, VERTICAL }
private data class SplitAlphaCandidate(
val orientation: SplitOrientation,
val alphaFirst: Boolean,
)
private data class SplitScore(
var direction: Double = 0.0,
var saturationGap: Double = 0.0,
var maskSaturation: Double = 0.0,
var maskGrayError: Double = 0.0,
var frames: Int = 0,
)
private data class PackedAlphaCandidate(
val frameWidth: Int,
val frameHeight: Int,
val alphaStartX: Int,
val alphaEndX: Int,
)
companion object {
const val VIEW_TYPE = "gift_mp4_video_view"
const val CHANNEL_NAME = "com.org.yumiparty/gift_mp4_events"
const val CONTROL_CHANNEL_NAME = "com.org.yumiparty/gift_mp4_control"
private val registry = mutableMapOf<Int, GiftMp4VideoPlatformView>()
private val layoutCache = mutableMapOf<String, SourceLayout>()
fun handleControlMethod(call: MethodCall, result: Result) {
val viewId = call.argument<Int>("viewId")
val view = viewId?.let { registry[it] }
if (view == null) {
result.success(null)
return
}
when (call.method) {
"setMute" -> {
view.setMute(call.argument<Boolean>("muted") ?: false)
result.success(null)
}
"stop" -> {
view.stop()
result.success(null)
}
else -> result.notImplemented()
}
}
private fun resolveSourceLayout(source: GiftMp4Source): SourceLayout {
layoutCache[source.cacheKey]?.let { return it }
val startedAt = SystemClock.elapsedRealtime()
val videoSize = probeVideoSize(source)
val layout =
parseVapcSourceLayout(source)
// Top-right packed alpha can look like a gray half-frame,
// so classify it before the looser split-alpha heuristic.
?: detectPackedAlphaTopRightSourceLayout(source)
?: detectSplitSourceLayout(source, videoSize)
?: normalSourceLayout(videoSize)
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
Log.d(
"GiftMp4Video",
"classified path=${source.path} mode=${layout.kind} elapsedMs=$elapsedMs",
)
layoutCache[source.cacheKey] = layout
trimLayoutCache()
return layout
}
private fun trimLayoutCache() {
while (layoutCache.size > 64) {
layoutCache.remove(layoutCache.keys.first())
}
}
private fun probeVideoSize(source: GiftMp4Source): VideoSize {
val retriever = source.newMetadataRetriever()
return try {
val width =
retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
?.toIntOrNull()
?: 1
val height =
retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
?.toIntOrNull()
?: 1
val rotation =
retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull()
?: 0
if (rotation == 90 || rotation == 270) {
VideoSize(height, width)
} else {
VideoSize(width, height)
}
} finally {
retriever.release()
}
}
private fun normalSourceLayout(videoSize: VideoSize): SourceLayout {
val fullFrame = SourceRect(0f, 0f, 1f, 1f)
return SourceLayout(
kind = "normal",
hasAlphaMask = false,
useTextureMatrix = true,
flipY = true,
contentAspectRatio = videoSize.aspectRatio,
colorRect = fullFrame,
alphaRect = fullFrame,
)
}
private fun splitSourceLayout(
candidate: SplitAlphaCandidate,
videoSize: VideoSize,
): SourceLayout {
val left = SourceRect(0f, 0f, 0.5f, 1f)
val right = SourceRect(0.5f, 0f, 0.5f, 1f)
val top = SourceRect(0f, 0.5f, 1f, 0.5f)
val bottom = SourceRect(0f, 0f, 1f, 0.5f)
val isHorizontal = candidate.orientation == SplitOrientation.HORIZONTAL
val first = if (isHorizontal) left else top
val second = if (isHorizontal) right else bottom
val outputAspectRatio =
if (isHorizontal) {
videoSize.width.toFloat() / 2f / videoSize.height.toFloat()
} else {
videoSize.width.toFloat() / (videoSize.height.toFloat() / 2f)
}
return SourceLayout(
kind = if (isHorizontal) "splitAlphaHorizontal" else "splitAlphaVertical",
hasAlphaMask = true,
useTextureMatrix = true,
flipY = true,
contentAspectRatio = outputAspectRatio,
colorRect = if (candidate.alphaFirst) second else first,
alphaRect = if (candidate.alphaFirst) first else second,
)
}
private fun parseVapcSourceLayout(source: GiftMp4Source): SourceLayout? {
return try {
val json = extractVapcJson(source.readBytes()) ?: return null
val info = JSONObject(json).getJSONObject("info")
val videoWidth = info.optDouble("videoW", 0.0).toFloat()
val videoHeight = info.optDouble("videoH", 0.0).toFloat()
val outputWidth = info.optDouble("w", 0.0).toFloat()
val outputHeight = info.optDouble("h", 0.0).toFloat()
if (videoWidth <= 0f || videoHeight <= 0f || outputWidth <= 0f || outputHeight <= 0f) {
return null
}
SourceLayout(
kind = "vapc",
hasAlphaMask = true,
useTextureMatrix = false,
flipY = false,
contentAspectRatio = outputWidth / outputHeight,
colorRect = parseNormalizedRect(info.getJSONArray("rgbFrame"), videoWidth, videoHeight),
alphaRect = parseNormalizedRect(info.getJSONArray("aFrame"), videoWidth, videoHeight),
)
} catch (_: Exception) {
null
}
}
private fun parseNormalizedRect(
values: JSONArray,
videoWidth: Float,
videoHeight: Float,
): SourceRect {
return SourceRect(
x = (values.getDouble(0).toFloat() / videoWidth).coerceIn(0f, 1f),
y = (values.getDouble(1).toFloat() / videoHeight).coerceIn(0f, 1f),
width = (values.getDouble(2).toFloat() / videoWidth).coerceIn(0f, 1f),
height = (values.getDouble(3).toFloat() / videoHeight).coerceIn(0f, 1f),
)
}
private fun extractVapcJson(bytes: ByteArray): String? {
val marker = byteArrayOf('v'.code.toByte(), 'a'.code.toByte(), 'p'.code.toByte(), 'c'.code.toByte())
var searchStart = 0
while (searchStart < bytes.size) {
val markerIndex = indexOf(bytes, marker, searchStart)
if (markerIndex < 0) return null
val scanEnd = minOf(bytes.size, markerIndex + 128)
val jsonStart =
(markerIndex + marker.size until scanEnd)
.firstOrNull { bytes[it] == '{'.code.toByte() }
if (jsonStart != null) {
val jsonEnd = findJsonEnd(bytes, jsonStart)
if (jsonEnd != null) {
return String(bytes, jsonStart, jsonEnd - jsonStart + 1, Charsets.UTF_8)
}
}
searchStart = markerIndex + marker.size
}
return null
}
private fun indexOf(
bytes: ByteArray,
marker: ByteArray,
start: Int,
): Int {
var index = start
while (index <= bytes.size - marker.size) {
var matched = true
for (offset in marker.indices) {
if (bytes[index + offset] != marker[offset]) {
matched = false
break
}
}
if (matched) return index
index++
}
return -1
}
private fun findJsonEnd(
bytes: ByteArray,
start: Int,
): Int? {
var depth = 0
var inString = false
var escaped = false
var index = start
while (index < bytes.size) {
val char = bytes[index].toInt().toChar()
if (inString) {
when {
escaped -> escaped = false
char == '\\' -> escaped = true
char == '"' -> inString = false
}
} else {
when (char) {
'"' -> inString = true
'{' -> depth++
'}' -> {
depth--
if (depth == 0) return index
}
}
}
index++
}
return null
}
private fun detectSplitSourceLayout(
source: GiftMp4Source,
videoSize: VideoSize,
): SourceLayout? {
val candidate = detectSplitAlpha(source) ?: return null
return splitSourceLayout(candidate, videoSize)
}
private fun detectSplitAlpha(source: GiftMp4Source): SplitAlphaCandidate? {
val retriever = source.newMetadataRetriever()
return try {
val durationMs =
retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull()
?: 0L
val timesUs =
listOf(0L, 500_000L, 1_000_000L, 2_000_000L)
.filter { durationMs == 0L || it <= durationMs * 1000L }
.ifEmpty { listOf(0L) }
val horizontalScore = SplitScore()
val verticalScore = SplitScore()
for (timeUs in timesUs) {
val frame =
retriever.getFrameAtTime(
timeUs,
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
) ?: continue
val horizontalStats = statsByHorizontalHalves(frame)
val verticalStats = statsByVerticalHalves(frame)
frame.recycle()
addSplitScore(horizontalScore, horizontalStats.first, horizontalStats.second)
addSplitScore(verticalScore, verticalStats.first, verticalStats.second)
}
val horizontalCandidate =
splitCandidateFromScore(SplitOrientation.HORIZONTAL, horizontalScore)
val verticalCandidate =
splitCandidateFromScore(SplitOrientation.VERTICAL, verticalScore)
listOfNotNull(horizontalCandidate, verticalCandidate)
.maxByOrNull { it.second }
?.first
} catch (_: RuntimeException) {
null
} finally {
retriever.release()
}
}
private fun detectPackedAlphaTopRightSourceLayout(
source: GiftMp4Source,
): SourceLayout? {
val candidate = detectPackedAlphaTopRight(source) ?: return null
val frameWidth = candidate.frameWidth.coerceAtLeast(1)
val frameHeight = candidate.frameHeight.coerceAtLeast(1)
val alphaStartX = candidate.alphaStartX.coerceIn(1, frameWidth - 1)
val alphaEndX = candidate.alphaEndX.coerceIn(alphaStartX, frameWidth - 1)
val colorWidth = alphaStartX
val alphaWidth = (alphaEndX - alphaStartX + 1).coerceAtLeast(1)
val alphaHeight =
(frameHeight.toFloat() * alphaWidth.toFloat() / colorWidth.toFloat() + 0.5f)
.toInt()
.coerceIn(1, frameHeight)
val alphaHeightRatio = alphaHeight.toFloat() / frameHeight.toFloat()
return SourceLayout(
kind = "packedAlphaTopRight",
hasAlphaMask = true,
useTextureMatrix = true,
flipY = true,
contentAspectRatio = colorWidth.toFloat() / frameHeight.toFloat(),
colorRect =
SourceRect(
x = 0f,
y = 0f,
width = colorWidth.toFloat() / frameWidth.toFloat(),
height = 1f,
),
alphaRect =
SourceRect(
x = alphaStartX.toFloat() / frameWidth.toFloat(),
y = 1f - alphaHeightRatio,
width = alphaWidth.toFloat() / frameWidth.toFloat(),
height = alphaHeightRatio,
),
)
}
private fun detectPackedAlphaTopRight(source: GiftMp4Source): PackedAlphaCandidate? {
val retriever = source.newMetadataRetriever()
return try {
val durationMs =
retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull()
?: 0L
val timesUs =
listOf(0L, 500_000L, 1_000_000L, 2_000_000L, 3_000_000L)
.filter { durationMs == 0L || it <= durationMs * 1000L }
.ifEmpty { listOf(0L) }
val candidates = mutableListOf<PackedAlphaCandidate>()
for (timeUs in timesUs) {
val frame =
retriever.getFrameAtTime(
timeUs,
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
) ?: continue
detectPackedAlphaTopRightInFrame(frame)?.let { candidates.add(it) }
frame.recycle()
}
if (candidates.isEmpty()) {
return null
}
val frameWidth = medianInt(candidates.map { it.frameWidth })
val frameHeight = medianInt(candidates.map { it.frameHeight })
val alphaStartX = medianInt(candidates.map { it.alphaStartX })
val alphaEndX = medianInt(candidates.map { it.alphaEndX })
val alphaWidth = alphaEndX - alphaStartX + 1
val alphaStartRatio = alphaStartX.toDouble() / frameWidth.toDouble()
val alphaWidthRatio = alphaWidth.toDouble() / frameWidth.toDouble()
val colorToAlphaRatio = alphaStartX.toDouble() / alphaWidth.toDouble()
if (alphaStartRatio !in 0.58..0.78 ||
alphaWidthRatio !in 0.18..0.42 ||
colorToAlphaRatio !in 1.45..2.65 ||
alphaEndX < frameWidth * 0.82
) {
return null
}
PackedAlphaCandidate(
frameWidth = frameWidth,
frameHeight = frameHeight,
alphaStartX = alphaStartX,
alphaEndX = alphaEndX,
)
} catch (_: RuntimeException) {
null
} finally {
retriever.release()
}
}
private fun detectPackedAlphaTopRightInFrame(bitmap: Bitmap): PackedAlphaCandidate? {
val width = bitmap.width
val height = bitmap.height
if (width < 12 || height < 12) {
return null
}
val searchStart = (width * 0.54f).toInt().coerceIn(0, width - 1)
val searchEnd = width
val sampleHeight = (height * 0.58f).toInt().coerceIn(1, height)
val stepY = maxOf(1, sampleHeight / 220)
val rowSamples = ((sampleHeight + stepY - 1) / stepY).coerceAtLeast(1)
val threshold = maxOf(8, (rowSamples * 0.32f).toInt())
val grayColumns = BooleanArray(width)
var x = searchStart
while (x < searchEnd) {
var grayCount = 0
var y = 0
while (y < sampleHeight) {
if (isLikelyAlphaMaskPixel(bitmap.getPixel(x, y))) {
grayCount++
}
y += stepY
}
grayColumns[x] = grayCount >= threshold
x++
}
val minRangeWidth = maxOf(24, (width * 0.12f).toInt())
var bestStart = -1
var bestEnd = -1
var index = searchStart
while (index < searchEnd) {
while (index < searchEnd && !grayColumns[index]) {
index++
}
if (index >= searchEnd) {
break
}
val start = index
while (index < searchEnd && grayColumns[index]) {
index++
}
val end = index - 1
if (end - start + 1 >= minRangeWidth && end > bestEnd) {
bestStart = start
bestEnd = end
}
}
if (bestStart < 0 || bestEnd < 0) {
return null
}
return PackedAlphaCandidate(
frameWidth = width,
frameHeight = height,
alphaStartX = bestStart,
alphaEndX = bestEnd,
)
}
private fun isLikelyAlphaMaskPixel(pixel: Int): Boolean {
val red = Color.red(pixel)
val green = Color.green(pixel)
val blue = Color.blue(pixel)
val maxChannel = maxOf(red, green, blue)
if (maxChannel < 22) {
return false
}
val minChannel = minOf(red, green, blue)
val saturation =
if (maxChannel == 0) 0.0 else (maxChannel - minChannel).toDouble() / maxChannel
val grayError =
(kotlin.math.abs(red - green) +
kotlin.math.abs(green - blue) +
kotlin.math.abs(blue - red)).toDouble() / (3.0 * 255.0)
return saturation < 0.16 && grayError < 0.055
}
private fun medianInt(values: List<Int>): Int {
if (values.isEmpty()) {
return 0
}
return values.sorted()[values.size / 2]
}
private fun addSplitScore(
score: SplitScore,
first: HalfStats,
second: HalfStats,
) {
val alphaStats = if (first.saturation < second.saturation) first else second
score.direction += second.saturation - first.saturation
score.saturationGap += kotlin.math.abs(second.saturation - first.saturation)
score.maskSaturation += alphaStats.saturation
score.maskGrayError += alphaStats.grayError
score.frames++
}
private fun splitCandidateFromScore(
orientation: SplitOrientation,
score: SplitScore,
): Pair<SplitAlphaCandidate, Double>? {
if (score.frames == 0) return null
val averageSaturationGap = score.saturationGap / score.frames
val averageMaskSaturation = score.maskSaturation / score.frames
val averageMaskGrayError = score.maskGrayError / score.frames
if (averageSaturationGap < 0.08 ||
averageMaskSaturation > 0.28 ||
averageMaskGrayError > 0.06
) {
return null
}
return SplitAlphaCandidate(
orientation = orientation,
alphaFirst = score.direction > 0.0,
) to averageSaturationGap
}
private fun statsByHorizontalHalves(bitmap: Bitmap): Pair<HalfStats, HalfStats> {
val half = bitmap.width / 2
return statsForRect(bitmap, 0, 0, half, bitmap.height) to
statsForRect(bitmap, half, 0, bitmap.width, bitmap.height)
}
private fun statsByVerticalHalves(bitmap: Bitmap): Pair<HalfStats, HalfStats> {
val half = bitmap.height / 2
return statsForRect(bitmap, 0, 0, bitmap.width, half) to
statsForRect(bitmap, 0, half, bitmap.width, bitmap.height)
}
private fun statsForRect(
bitmap: Bitmap,
startX: Int,
startY: Int,
endX: Int,
endY: Int,
): HalfStats {
val stepX = maxOf(1, (endX - startX) / 80)
val stepY = maxOf(1, (endY - startY) / 120)
var saturationSum = 0.0
var grayErrorSum = 0.0
var count = 0
var y = startY
while (y < endY) {
var x = startX
while (x < endX) {
val pixel = bitmap.getPixel(x, y)
val red = Color.red(pixel) / 255.0
val green = Color.green(pixel) / 255.0
val blue = Color.blue(pixel) / 255.0
val maxChannel = maxOf(red, green, blue)
val minChannel = minOf(red, green, blue)
saturationSum +=
if (maxChannel == 0.0) 0.0 else (maxChannel - minChannel) / maxChannel
grayErrorSum +=
(kotlin.math.abs(red - green) +
kotlin.math.abs(green - blue) +
kotlin.math.abs(blue - red)) / 3.0
count++
x += stepX
}
y += stepY
}
if (count == 0) return HalfStats(saturation = 0.0, grayError = 0.0)
return HalfStats(
saturation = saturationSum / count,
grayError = grayErrorSum / count,
)
}
}
}

View File

@ -0,0 +1,16 @@
package com.org.yumiparty
import android.content.Context
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class GiftMp4VideoViewFactory(
private val messenger: BinaryMessenger,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
val params = args as? Map<*, *> ?: emptyMap<Any, Any>()
return GiftMp4VideoPlatformView(context, viewId, params, messenger)
}
}

View File

@ -58,6 +58,19 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
GiftMp4VideoPlatformView.CONTROL_CHANNEL_NAME
).setMethodCallHandler { call, result ->
GiftMp4VideoPlatformView.handleControlMethod(call, result)
}
flutterEngine
.platformViewsController
.registry
.registerViewFactory(
GiftMp4VideoPlatformView.VIEW_TYPE,
GiftMp4VideoViewFactory(flutterEngine.dartExecutor.binaryMessenger)
)
}
override fun onNewIntent(intent: Intent) {

Binary file not shown.

View File

@ -16,8 +16,8 @@ class SCVariant1Config implements AppConfig {
@override
String get apiHost => const String.fromEnvironment(
'API_HOST',
defaultValue: 'http://192.168.110.64:1100/',
); // 线 --dart-define=API_HOST http://192.168.110.64:1100/线https://jvapi.haiyihy.com/
defaultValue: 'http://192.168.110.66:1100/',
); // 线 --dart-define=API_HOST http://192.168.110.66:1100/线https://jvapi.haiyihy.com/
@override
String get imgHost => 'https://img.atuchat.com/'; //

View File

@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart';
const _demoVideoPath = 'assets/debug/manager-packed-alpha.mp4';
const _demoBackgroundColor = Color(0xFF7C3AED);
void main() {
runApp(const GiftMp4DemoApp());
}
class GiftMp4DemoApp extends StatelessWidget {
const GiftMp4DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(useMaterial3: true),
home: const GiftMp4DemoPage(),
);
}
}
class GiftMp4DemoPage extends StatefulWidget {
const GiftMp4DemoPage({super.key});
@override
State<GiftMp4DemoPage> createState() => _GiftMp4DemoPageState();
}
class _GiftMp4DemoPageState extends State<GiftMp4DemoPage> {
late final SCGiftMp4Controller _controller;
@override
void initState() {
super.initState();
_controller = SCGiftMp4Controller();
_controller.setPlaybackListener(
onComplete: () => unawaited(_replay()),
onFailed: (error) => debugPrint('[GiftMp4Demo] failed: $error'),
);
WidgetsBinding.instance.addPostFrameCallback((_) => _replay());
}
Future<void> _replay() async {
await _controller.playAsset(_demoVideoPath);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _demoBackgroundColor,
body: Stack(
children: [
Positioned.fill(child: GiftMp4EffectView(controller: _controller)),
Positioned(
left: 16,
right: 16,
bottom: 32,
child: Row(
children: [
FilledButton(onPressed: _replay, child: const Text('Replay')),
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: _controller.stop,
child: const Text('Stop'),
),
],
),
),
],
),
);
}
}

View File

@ -108,7 +108,9 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
Timer? _comboSendBatchTimer;
bool _isComboSendBatchInFlight = false;
void _giftFxLog(String message) {}
void _giftFxLog(String message) {
debugPrint('[GiftFx][gift] $message');
}
String _describeGiftSendError(Object error) {
if (error is DioException) {

View File

@ -1,17 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/sc_page_list.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/ui_kit/widgets/store/props_store_chatbox_detail_dialog.dart';
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
@ -20,8 +13,10 @@ import '../../../shared/data_sources/models/enum/sc_props_type.dart';
///
class StoreChatboxPage extends SCPageList {
const StoreChatboxPage({super.key});
@override
_StoreChatboxPageState createState() => _StoreChatboxPageState();
SCPageListState createState() => _StoreChatboxPageState();
}
class _StoreChatboxPageState
@ -41,7 +36,7 @@ class _StoreChatboxPageState
crossAxisCount: gridViewCount,
mainAxisSpacing: 12.w,
crossAxisSpacing: 12.w,
childAspectRatio: 0.78,
childAspectRatio: 0.88,
);
loadData(1);
}
@ -59,100 +54,13 @@ class _StoreChatboxPageState
@override
Widget buildItem(StoreListResBean res) {
return GestureDetector(
child: Container(
decoration: BoxDecoration(
color:
res.isSelecte
? const Color(0xff18F2B1).withValues(alpha: 0.1)
: SCGlobalConfig.businessLogicStrategy
.getStoreItemBackgroundColor(),
border: Border.all(
color:
res.isSelecte
? SCGlobalConfig.businessLogicStrategy
.getStoreItemSelectedBorderColor()
: SCGlobalConfig.businessLogicStrategy
.getStoreItemUnselectedBorderColor(),
width: 1.w,
),
borderRadius: BorderRadius.all(Radius.circular(8.w)),
),
child: Stack(
children: [
Column(
children: [
SizedBox(height: 10.w),
netImage(
url: res.res.propsResources?.cover ?? "",
height: 45.w,
fit: BoxFit.contain,
),
SizedBox(height: 10.w),
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 10.w),
Expanded(
child: Text.rich(
textAlign: TextAlign.center,
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Image.asset(
SCGlobalConfig.businessLogicStrategy
.getStoreItemGoldIcon(),
width: 22.w,
),
),
TextSpan(text: " "),
disCount < 1
? TextSpan(
text: "${res.res.propsPrices![0].amount}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
decoration: TextDecoration.lineThrough,
),
)
: TextSpan(),
disCount < 1 ? TextSpan(text: " ") : TextSpan(),
TextSpan(
text:
"${((res.res.propsPrices![0].amount ?? 0) * disCount).toInt()}/${res.res.propsPrices![0].days} ${SCAppLocalizations.of(context)!.day}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
),
),
],
),
strutStyle: StrutStyle(
height: 1.3, //
fontWeight: FontWeight.w500,
forceStrutHeight: true, //
),
),
),
SizedBox(width: 10.w),
],
),
],
),
],
),
),
onTap: () {
_selectItem(res);
_showDetail(res.res);
},
return SCStoreGridItemCard(
res: res.res,
discount: disCount,
coverHeight: 45.w,
previewWidth: 88.w,
previewHeight: 58.w,
onDetailsTap: () => _showDetail(res.res),
);
}
@ -190,40 +98,6 @@ class _StoreChatboxPageState
},
);
}
void _selectItem(StoreListResBean res) {
for (var value in items) {
value.isSelecte = false;
}
res.isSelecte = true;
setState(() {});
}
void _buy(StoreListResBean res) {
if (res.res.propsPrices!.isEmpty) {
return;
}
SCLoadingManager.show();
num days = res.res.propsPrices![0].days ?? 0;
SCStoreRepositoryImp()
.storePurchasing(
res.res.id ?? "",
SCPropsType.CHAT_BUBBLE.name,
SCCurrencyType.GOLD.name,
"$days",
)
.then((value) {
SCTts.show(SCAppLocalizations.of(context)!.purchaseIsSuccessful);
Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
).updateBalance(value);
SCLoadingManager.hide();
})
.catchError((e) {
SCLoadingManager.hide();
});
}
}
class StoreListResBean {

View File

@ -1,13 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_currency_type.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_props_type.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_page_list.dart';
import 'package:yumi/ui_kit/widgets/store/props_store_data_card_detail_dialog.dart';
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
@ -35,7 +32,7 @@ class StoreDataCardPageState
crossAxisCount: gridViewCount,
mainAxisSpacing: 12.w,
crossAxisSpacing: 12.w,
childAspectRatio: 0.78,
childAspectRatio: 0.88,
);
loadData(1);
}
@ -54,101 +51,18 @@ class StoreDataCardPageState
@override
Widget buildItem(StoreDataCardListResBean item) {
final res = item;
final prices = res.res.propsPrices ?? const <PropsPrices>[];
final firstPrice = prices.isNotEmpty ? prices.first : null;
return GestureDetector(
child: Container(
decoration: BoxDecoration(
color:
res.isSelecte
? const Color(0xff18F2B1).withValues(alpha: 0.1)
: SCGlobalConfig.businessLogicStrategy
.getStoreItemBackgroundColor(),
border: Border.all(
color:
res.isSelecte
? SCGlobalConfig.businessLogicStrategy
.getStoreItemSelectedBorderColor()
: SCGlobalConfig.businessLogicStrategy
.getStoreItemUnselectedBorderColor(),
width: 1.w,
),
borderRadius: BorderRadius.all(Radius.circular(8.w)),
),
child: Column(
children: [
SizedBox(height: 10.w),
ClipRRect(
borderRadius: BorderRadius.circular(6.w),
child: netImage(
url: res.res.propsResources?.cover ?? "",
width: 72.w,
height: 50.w,
fit: BoxFit.cover,
),
),
SizedBox(height: 12.w),
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 10.w),
Expanded(
child: Text.rich(
textAlign: TextAlign.center,
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Image.asset(
SCGlobalConfig.businessLogicStrategy
.getStoreItemGoldIcon(),
width: 22.w,
),
),
const TextSpan(text: " "),
if (disCount < 1)
TextSpan(
text: "${firstPrice?.amount ?? 0}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
decoration: TextDecoration.lineThrough,
),
),
if (disCount < 1) const TextSpan(text: " "),
TextSpan(
text:
"${((firstPrice?.amount ?? 0) * disCount).toInt()}/${firstPrice?.days ?? 0} ${SCAppLocalizations.of(context)!.day}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
),
),
],
),
strutStyle: const StrutStyle(
height: 1.3,
fontWeight: FontWeight.w500,
forceStrutHeight: true,
),
),
),
SizedBox(width: 10.w),
],
),
],
),
),
onTap: () {
_selectItem(res);
_showDetail(res.res);
},
return SCStoreGridItemCard(
res: res.res,
discount: disCount,
previewKind: SCStoreItemPreviewKind.dataCard,
coverWidth: 72.w,
coverHeight: 50.w,
previewWidth: 88.w,
previewHeight: 58.w,
coverFit: BoxFit.cover,
previewFit: BoxFit.cover,
coverBorderRadius: BorderRadius.circular(6.w),
onDetailsTap: () => _showDetail(res.res),
);
}
@ -183,14 +97,6 @@ class StoreDataCardPageState
},
);
}
void _selectItem(StoreDataCardListResBean res) {
for (final value in items) {
value.isSelecte = false;
}
res.isSelecte = true;
setState(() {});
}
}
class StoreDataCardListResBean {

View File

@ -1,16 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/sc_page_list.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/ui_kit/widgets/store/props_store_headdress_detail_dialog.dart';
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
@ -19,8 +12,10 @@ import '../../../shared/data_sources/models/enum/sc_props_type.dart';
///
class StoreHeaddressPage extends SCPageList {
const StoreHeaddressPage({super.key});
@override
_StoreHeaddressPageState createState() => _StoreHeaddressPageState();
SCPageListState createState() => _StoreHeaddressPageState();
}
class _StoreHeaddressPageState
@ -40,7 +35,7 @@ class _StoreHeaddressPageState
crossAxisCount: gridViewCount,
mainAxisSpacing: 12.w,
crossAxisSpacing: 12.w,
childAspectRatio: 0.78,
childAspectRatio: 0.88,
);
loadData(1);
}
@ -58,100 +53,11 @@ class _StoreHeaddressPageState
@override
Widget buildItem(StoreListResBean res) {
return GestureDetector(
child: Container(
decoration: BoxDecoration(
color:
res.isSelecte
? const Color(0xff18F2B1).withValues(alpha: 0.1)
: SCGlobalConfig.businessLogicStrategy
.getStoreItemBackgroundColor(),
border: Border.all(
color:
res.isSelecte
? SCGlobalConfig.businessLogicStrategy
.getStoreItemSelectedBorderColor()
: SCGlobalConfig.businessLogicStrategy
.getStoreItemUnselectedBorderColor(),
width: 1.w,
),
borderRadius: BorderRadius.all(Radius.circular(8.w)),
),
child: Stack(
children: [
Column(
children: [
SizedBox(height: 10.w),
netImage(
url: res.res.propsResources?.cover ?? "",
width: 55.w,
height: 55.w,
),
SizedBox(height: 10.w),
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 10.w),
Expanded(
child: Text.rich(
textAlign: TextAlign.center,
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Image.asset(
SCGlobalConfig.businessLogicStrategy
.getStoreItemGoldIcon(),
width: 22.w,
),
),
TextSpan(text: " "),
disCount < 1
? TextSpan(
text: "${res.res.propsPrices![0].amount}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
decoration: TextDecoration.lineThrough,
),
)
: TextSpan(),
disCount < 1 ? TextSpan(text: " ") : TextSpan(),
TextSpan(
text:
"${((res.res.propsPrices![0].amount ?? 0) * disCount).toInt()}/${res.res.propsPrices![0].days} ${SCAppLocalizations.of(context)!.day}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
),
),
],
),
strutStyle: StrutStyle(
height: 1.3, //
fontWeight: FontWeight.w500,
forceStrutHeight: true, //
),
),
),
SizedBox(width: 10.w),
],
),
],
),
],
),
),
onTap: () {
_selectItem(res);
_showDetail(res.res);
},
return SCStoreGridItemCard(
res: res.res,
discount: disCount,
previewKind: SCStoreItemPreviewKind.avatarFrame,
onDetailsTap: () => _showDetail(res.res),
);
}
@ -189,40 +95,6 @@ class _StoreHeaddressPageState
},
);
}
void _selectItem(StoreListResBean res) {
for (var value in items) {
value.isSelecte = false;
}
res.isSelecte = true;
setState(() {});
}
void _buy(StoreListResBean res) {
if (res.res.propsPrices!.isEmpty) {
return;
}
SCLoadingManager.show();
num days = res.res.propsPrices![0].days ?? 0;
SCStoreRepositoryImp()
.storePurchasing(
res.res.id ?? "",
SCPropsType.AVATAR_FRAME.name,
SCCurrencyType.GOLD.name,
"$days",
)
.then((value) {
SCTts.show(SCAppLocalizations.of(context)!.purchaseIsSuccessful);
Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
).updateBalance(value);
SCLoadingManager.hide();
})
.catchError((e) {
SCLoadingManager.hide();
});
}
}
class StoreListResBean {

View File

@ -1,10 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_page_list.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
import 'package:yumi/ui_kit/widgets/store/props_store_mountains_detail_dialog.dart';
@ -16,10 +13,10 @@ import '../../../shared/data_sources/models/enum/sc_props_type.dart';
///
class StoreMountainsPage extends SCPageList {
StoreMountainsPage();
const StoreMountainsPage({super.key});
@override
_StoreMountainsPageState createState() => _StoreMountainsPageState();
SCPageListState createState() => _StoreMountainsPageState();
}
class _StoreMountainsPageState
@ -39,16 +36,11 @@ class _StoreMountainsPageState
crossAxisCount: gridViewCount,
mainAxisSpacing: 12.w,
crossAxisSpacing: 12.w,
childAspectRatio: 0.78,
childAspectRatio: 0.88,
);
loadData(1);
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -62,100 +54,10 @@ class _StoreMountainsPageState
@override
Widget buildItem(StoreListResBean res) {
return GestureDetector(
child: Container(
decoration: BoxDecoration(
color:
res.isSelecte
? const Color(0xff18F2B1).withValues(alpha: 0.1)
: SCGlobalConfig.businessLogicStrategy
.getStoreItemBackgroundColor(),
border: Border.all(
color:
res.isSelecte
? SCGlobalConfig.businessLogicStrategy
.getStoreItemSelectedBorderColor()
: SCGlobalConfig.businessLogicStrategy
.getStoreItemUnselectedBorderColor(),
width: 1.w,
),
borderRadius: BorderRadius.all(Radius.circular(8.w)),
),
child: Stack(
children: [
Column(
children: [
SizedBox(height: 10.w),
netImage(
url: res.res.propsResources?.cover ?? "",
width: 55.w,
height: 55.w,
),
SizedBox(height: 10.w),
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 10.w),
Expanded(
child: Text.rich(
textAlign: TextAlign.center,
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Image.asset(
SCGlobalConfig.businessLogicStrategy
.getStoreItemGoldIcon(),
width: 22.w,
),
),
TextSpan(text: " "),
disCount < 1
? TextSpan(
text: "${res.res.propsPrices![0].amount}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
decoration: TextDecoration.lineThrough,
),
)
: TextSpan(),
disCount < 1 ? TextSpan(text: " ") : TextSpan(),
TextSpan(
text:
"${((res.res.propsPrices![0].amount ?? 0) * disCount).toInt()}/${res.res.propsPrices![0].days} ${SCAppLocalizations.of(context)!.day}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
),
),
],
),
strutStyle: StrutStyle(
height: 1.3, //
fontWeight: FontWeight.w500,
forceStrutHeight: true, //
),
),
),
SizedBox(width: 10.w),
],
),
],
),
],
),
),
onTap: () {
_selectItem(res);
_showDetail(res.res);
},
return SCStoreGridItemCard(
res: res.res,
discount: disCount,
onDetailsTap: () => _showDetail(res.res),
);
}
@ -193,12 +95,4 @@ class _StoreMountainsPageState
},
);
}
void _selectItem(StoreListResBean res) {
for (var value in items) {
value.isSelecte = false;
}
res.isSelecte = true;
setState(() {});
}
}

View File

@ -1,16 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_page_list.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/ui_kit/widgets/store/props_store_theme_detail_dialog.dart';
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
import 'package:yumi/modules/store/headdress/store_headdress_page.dart';
@ -20,10 +14,10 @@ import '../../../shared/data_sources/models/enum/sc_props_type.dart';
///
class StoreThemePage extends SCPageList {
StoreThemePage();
const StoreThemePage({super.key});
@override
_StoreThemePagePageState createState() => _StoreThemePagePageState();
SCPageListState createState() => _StoreThemePagePageState();
}
class _StoreThemePagePageState
@ -43,7 +37,7 @@ class _StoreThemePagePageState
crossAxisCount: gridViewCount,
mainAxisSpacing: 12.w,
crossAxisSpacing: 12.w,
childAspectRatio: 0.78,
childAspectRatio: 0.88,
);
loadData(1);
}
@ -61,101 +55,11 @@ class _StoreThemePagePageState
@override
Widget buildItem(StoreListResBean res) {
return GestureDetector(
child: Container(
decoration: BoxDecoration(
color:
res.isSelecte
? const Color(0xff18F2B1).withValues(alpha: 0.1)
: SCGlobalConfig.businessLogicStrategy
.getStoreItemBackgroundColor(),
border: Border.all(
color:
res.isSelecte
? SCGlobalConfig.businessLogicStrategy
.getStoreItemSelectedBorderColor()
: SCGlobalConfig.businessLogicStrategy
.getStoreItemUnselectedBorderColor(),
width: 1.w,
),
borderRadius: BorderRadius.all(Radius.circular(8.w)),
),
child: Stack(
children: [
Column(
children: [
SizedBox(height: 10.w),
netImage(
url: res.res.propsResources?.cover ?? "",
width: 55.w,
height: 55.w,
borderRadius: BorderRadius.all(Radius.circular(8.w)),
),
SizedBox(height: 10.w),
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 10.w),
Expanded(
child: Text.rich(
textAlign: TextAlign.center,
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Image.asset(
SCGlobalConfig.businessLogicStrategy
.getStoreItemGoldIcon(),
width: 22.w,
),
),
TextSpan(text: " "),
disCount < 1
? TextSpan(
text: "${res.res.propsPrices![0].amount}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
decoration: TextDecoration.lineThrough,
),
)
: TextSpan(),
disCount < 1 ? TextSpan(text: " ") : TextSpan(),
TextSpan(
text:
"${((res.res.propsPrices![0].amount ?? 0) * disCount).toInt()}/${res.res.propsPrices![0].days} ${SCAppLocalizations.of(context)!.day}",
style: TextStyle(
fontSize: 12.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
),
),
],
),
strutStyle: StrutStyle(
height: 1.3, //
fontWeight: FontWeight.w500,
forceStrutHeight: true, //
),
),
),
SizedBox(width: 10.w),
],
),
],
),
],
),
),
onTap: () {
_selectItem(res);
_showDetail(res.res);
},
return SCStoreGridItemCard(
res: res.res,
discount: disCount,
coverBorderRadius: BorderRadius.all(Radius.circular(8.w)),
onDetailsTap: () => _showDetail(res.res),
);
}
@ -193,35 +97,4 @@ class _StoreThemePagePageState
}
}
}
void _selectItem(StoreListResBean res) {
for (var value in items) {
value.isSelecte = false;
}
res.isSelecte = true;
setState(() {});
}
void _buy(StoreListResBean res) {
num days = 0;
if (res.res.propsPrices!.length > 1) {
days = res.res.propsPrices![1].days ?? 0;
} else {
days = res.res.propsPrices![0].days ?? 0;
}
SCStoreRepositoryImp()
.storePurchasing(
res.res.id ?? "",
SCPropsType.THEME.name,
SCCurrencyType.GOLD.name,
"$days",
)
.then((value) {
SCTts.show(SCAppLocalizations.of(context)!.purchaseIsSuccessful);
Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
).updateBalance(value);
});
}
}

View File

@ -63,7 +63,7 @@ class _BagsChatboxPageState
Expanded(
child:
items.isEmpty && isLoading
? const SCBagGridSkeleton()
? const SCBagGridSkeleton(showTitle: false)
: buildList(context),
),
selecteChatbox != null ? SizedBox(height: 10.w) : Container(),
@ -234,19 +234,14 @@ class _BagsChatboxPageState
alignment: Alignment.center,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 25.w),
netImage(
url: res.propsResources?.cover ?? "",
height: 35.w,
width: 96.w,
height: 52.w,
fit: BoxFit.contain,
),
SizedBox(height: 8.w),
buildStoreBagItemTitle(
res.propsResources?.name ?? "",
textColor: Colors.black,
fontSize: 12.sp,
),
],
),
PositionedDirectional(
@ -308,6 +303,11 @@ class _BagsChatboxPageState
onTap: () {
selecteChatbox = res;
setState(() {});
showStoreBagFullScreenResourcePreview(
context,
resource: res.propsResources,
fit: BoxFit.contain,
);
},
);
}

View File

@ -69,7 +69,7 @@ class BagsDataCardPageState
Expanded(
child:
items.isEmpty && isLoading
? const SCBagGridSkeleton()
? const SCBagGridSkeleton(showTitle: false)
: buildList(context),
),
hasSelection ? SizedBox(height: 10.w) : const SizedBox.shrink(),
@ -243,23 +243,17 @@ class BagsDataCardPageState
alignment: Alignment.center,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 25.w),
ClipRRect(
borderRadius: BorderRadius.circular(6.w),
borderRadius: BorderRadius.circular(8.w),
child: netImage(
url: res.propsResources?.cover ?? "",
width: 72.w,
height: 50.w,
width: 108.w,
height: 75.w,
fit: BoxFit.cover,
),
),
SizedBox(height: 8.w),
buildStoreBagItemTitle(
res.propsResources?.name ?? "",
textColor: Colors.white,
fontSize: 12.sp,
),
],
),
PositionedDirectional(
@ -321,6 +315,12 @@ class BagsDataCardPageState
onTap: () {
selectedDataCard = res;
setState(() {});
showStoreBagFullScreenResourcePreview(
context,
resource: res.propsResources,
previewKind: SCStoreItemPreviewKind.dataCard,
fit: BoxFit.contain,
);
},
);
}

View File

@ -47,7 +47,7 @@ class _BagsGiftPageState
backgroundColor: Colors.transparent,
body:
items.isEmpty && isLoading
? const SCBagGridSkeleton()
? const SCBagGridSkeleton(showTitle: false)
: buildList(context),
);
}
@ -62,6 +62,12 @@ class _BagsGiftPageState
onTap: () {
selectedGift = res;
setState(() {});
showStoreBagFullScreenResourcePreview(
context,
sourceUrl: gift?.giftSourceUrl,
coverUrl: gift?.giftPhoto,
fit: BoxFit.contain,
);
},
child: Container(
decoration: BoxDecoration(
@ -77,21 +83,15 @@ class _BagsGiftPageState
alignment: Alignment.center,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 25.w),
netImage(
url: gift?.giftPhoto ?? "",
width: 55.w,
height: 55.w,
width: 82.w,
height: 82.w,
fit: BoxFit.contain,
),
SizedBox(height: 8.w),
buildStoreBagItemTitle(
gift?.giftName ?? "",
textColor: Colors.white,
fontSize: 12,
),
SizedBox(height: 6.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [

View File

@ -63,7 +63,7 @@ class _BagsHeaddressPageState
Expanded(
child:
items.isEmpty && isLoading
? const SCBagGridSkeleton()
? const SCBagGridSkeleton(showTitle: false)
: buildList(context),
),
selecteHeaddress != null ? SizedBox(height: 10.w) : Container(),
@ -234,18 +234,13 @@ class _BagsHeaddressPageState
alignment: Alignment.center,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 25.w),
netImage(
url: res.propsResources?.cover ?? "",
width: 55.w,
height: 55.w,
),
SizedBox(height: 8.w),
buildStoreBagItemTitle(
res.propsResources?.name ?? "",
textColor: Colors.white,
fontSize: 12.sp,
width: 82.w,
height: 82.w,
fit: BoxFit.contain,
),
],
),
@ -308,6 +303,12 @@ class _BagsHeaddressPageState
onTap: () {
selecteHeaddress = res;
setState(() {});
showStoreBagFullScreenResourcePreview(
context,
resource: res.propsResources,
previewKind: SCStoreItemPreviewKind.avatarFrame,
fit: BoxFit.contain,
);
},
);
}

View File

@ -69,7 +69,7 @@ class _BagsMountainsPageState
Expanded(
child:
items.isEmpty && isLoading
? const SCBagGridSkeleton()
? const SCBagGridSkeleton(showTitle: false)
: buildList(context),
),
selecteMountains != null ? SizedBox(height: 10.w) : Container(),
@ -240,18 +240,13 @@ class _BagsMountainsPageState
alignment: Alignment.center,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 25.w),
netImage(
url: res.propsResources?.cover ?? "",
width: 55.w,
height: 55.w,
),
SizedBox(height: 8.w),
buildStoreBagItemTitle(
res.propsResources?.name ?? "",
textColor: Colors.white,
fontSize: 12.sp,
width: 82.w,
height: 82.w,
fit: BoxFit.contain,
),
],
),
@ -293,6 +288,11 @@ class _BagsMountainsPageState
onTap: () {
selecteMountains = res;
setState(() {});
showStoreBagFullScreenResourcePreview(
context,
resource: res.propsResources,
fit: BoxFit.contain,
);
},
);
}

View File

@ -227,15 +227,14 @@ class _BagsThemePageState
alignment: Alignment.center,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 25.w),
netImage(
url: res.themeBack ?? "",
width: 55.w,
height: 55.w,
width: 82.w,
height: 82.w,
borderRadius: BorderRadius.all(Radius.circular(8.w)),
),
SizedBox(height: 8.w),
],
),
PositionedDirectional(
@ -276,6 +275,12 @@ class _BagsThemePageState
onTap: () {
selecteTheme = res;
setState(() {});
showStoreBagFullScreenResourcePreview(
context,
sourceUrl: res.themeBack,
coverUrl: res.themeBack,
fit: BoxFit.contain,
);
},
);
}

View File

@ -436,6 +436,29 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
);
}
Widget _buildHeaderLongBadgeStrip(SocialChatUserProfileManager ref) {
return SizedBox(
width: ScreenUtil().screenWidth - 40.w,
child: SCUserBadgeStrip(
userId: ref.userProfile?.id ?? widget.tageId,
fallbackBadges:
ref.userProfile?.wearBadge
?.where((item) => item.use ?? false)
.toList() ??
const <WearBadge>[],
displayScope: SCUserBadgeDisplayScope.long,
height: 45.w,
badgeHeight: 20.w,
longBadgeWidth: 62.w,
spacing: 4.w,
wrap: true,
maxWrapRows: 2,
scrollLastWrapRow: true,
reserveSpace: false,
),
);
}
Widget _buildHeaderIntroduction(SocialChatUserProfileManager ref) {
final intro = (ref.userProfile?.autograph ?? "").trim();
return Container(
@ -610,7 +633,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
PropsResources? dataCard,
) {
return SizedBox(
height: 520.w,
height: 570.w,
child: Stack(
children: [
SizedBox(
@ -674,6 +697,8 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
SizedBox(height: 5.w),
_buildHeaderMeta(ref),
SizedBox(height: 4.w),
_buildHeaderLongBadgeStrip(ref),
SizedBox(height: 4.w),
_buildHeaderBadgeStrip(ref),
SizedBox(height: 6.w),
_buildHeaderIntroduction(ref),

View File

@ -156,7 +156,9 @@ class RealTimeMessagingManager extends ChangeNotifier {
BuildContext? context;
void _giftFxLog(String message) {}
void _giftFxLog(String message) {
debugPrint('[GiftFx][rtm] $message');
}
void _enqueueGiftFloatingMessage(
SCFloatingMessage message, {

View File

@ -32,18 +32,32 @@ class SCUserBadgeRes {
final List<WearBadge> longBadges;
final List<WearBadge> shortBadges;
List<WearBadge> get displayBadges {
List<WearBadge> get allBadges {
if (longBadges.isNotEmpty || shortBadges.isNotEmpty) {
return <WearBadge>[...longBadges, ...shortBadges];
}
return badges;
}
List<WearBadge> get displayBadges {
return shortDisplayBadges;
}
List<WearBadge> get shortDisplayBadges {
final source = shortBadges.isNotEmpty ? shortBadges : badges;
return source.where(isShortDisplayBadge).toList();
}
List<WearBadge> get longDisplayBadges {
final source = longBadges.isNotEmpty ? longBadges : badges;
return source.where(isLongDisplayBadge).toList();
}
List<WearBadge> get honorWallBadges {
final source =
shortBadges.isNotEmpty
? shortBadges
: displayBadges.where(_isHonorWallBadge).toList();
? shortBadges.where(_isHonorWallBadge).toList()
: allBadges.where(_isHonorWallBadge).toList();
final result =
source.where((badge) => _badgeUrl(badge).isNotEmpty).toList();
result.sort((a, b) => _badgeSortValue(b).compareTo(_badgeSortValue(a)));
@ -103,20 +117,36 @@ class SCUserBadgeRes {
type == "ACHIEVEMENT";
}
static bool _isHonorWallBadge(WearBadge badge) {
static bool isShortDisplayBadge(WearBadge badge) {
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
final type = badge.type?.trim().toUpperCase() ?? "";
if (type == "LONG" || type == "VIP" || sourceType == "VIP") {
return false;
}
if (sourceType.isNotEmpty) {
return sourceType == "ACTIVITY" || sourceType == "ACHIEVEMENT";
return sourceType == "ACTIVITY" ||
sourceType == "ACHIEVEMENT" ||
sourceType == "HONOR_ACTIVITY" ||
sourceType == "HONOR_ADMIN";
}
return type == "SHORT" ||
type == "ACTIVITY" ||
type == "ACHIEVEMENT" ||
type == "HONOR_ACTIVITY" ||
type == "HONOR_ADMIN";
if (type.isNotEmpty) {
return type == "SHORT" ||
type == "ACTIVITY" ||
type == "ACHIEVEMENT" ||
type == "HONOR_ACTIVITY" ||
type == "HONOR_ADMIN";
}
return true;
}
static bool isLongDisplayBadge(WearBadge badge) {
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
final type = badge.type?.trim().toUpperCase() ?? "";
return type == "LONG" || type == "VIP" || sourceType == "VIP";
}
static bool _isHonorWallBadge(WearBadge badge) {
return isShortDisplayBadge(badge);
}
static int _badgeSortValue(WearBadge badge) {

View File

@ -1,71 +1,84 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:uuid/uuid.dart';
class SCDeviceIdUtils {
static Future<void> initDeviceinfo() async {
final info = await PackageInfo.fromPlatform();
SCGlobalConfig.version = info.version;
SCGlobalConfig.build = info.buildNumber;
final processorCount = Platform.numberOfProcessors;
if (Platform.isAndroid) {
SCGlobalConfig.channel = "Google";
final dev = await DeviceInfoPlugin().androidInfo;
SCGlobalConfig.model = dev.model;
SCGlobalConfig.sysVersion = dev.version.release;
SCGlobalConfig.sdkInt = dev.version.sdkInt;
SCGlobalConfig.applyDevicePerformanceProfile(
isLowRamDevice: dev.isLowRamDevice,
processorCount: processorCount,
);
} else if (Platform.isIOS) {
SCGlobalConfig.channel = "AppStore";
final dev = await DeviceInfoPlugin().iosInfo;
SCGlobalConfig.model = dev.model;
SCGlobalConfig.sysVersion = dev.systemVersion;
SCGlobalConfig.applyDevicePerformanceProfile(
isLowRamDevice: false,
processorCount: processorCount,
);
}
}
// ID
static Future<String> getDeviceId({bool forceRefresh = false}) async {
//
if (forceRefresh || SCGlobalConfig.imei.isEmpty) {
final newId = await _g();
DataPersistence.setUniqueId(newId);
SCGlobalConfig.imei = newId;
return newId;
}
// ID
String storedId = DataPersistence.getUniqueId();
if (storedId.isNotEmpty) {
SCGlobalConfig.imei = storedId;
return storedId;
}
// ID并存储
final newId = await _g();
DataPersistence.setUniqueId(newId);
SCGlobalConfig.imei = newId;
return newId;
}
// ID
static Future<String> _g() async {
final packageInfo = await PackageInfo.fromPlatform();
// UUID
final newId = '${const Uuid().v4()}-${packageInfo.packageName}';
DataPersistence.setUniqueId(newId);
return newId;
}
}
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:uuid/uuid.dart';
class SCDeviceIdUtils {
static Future<void> initDeviceinfo() async {
final info = await PackageInfo.fromPlatform();
SCGlobalConfig.version = info.version;
SCGlobalConfig.build = info.buildNumber;
final processorCount = Platform.numberOfProcessors;
if (Platform.isAndroid) {
SCGlobalConfig.channel = "Google";
final dev = await DeviceInfoPlugin().androidInfo;
SCGlobalConfig.model = dev.model;
SCGlobalConfig.sysVersion = dev.version.release;
SCGlobalConfig.sdkInt = dev.version.sdkInt;
SCGlobalConfig.applyDevicePerformanceProfile(
isLowRamDevice: dev.isLowRamDevice,
processorCount: processorCount,
);
_logPerformanceProfile();
} else if (Platform.isIOS) {
SCGlobalConfig.channel = "AppStore";
final dev = await DeviceInfoPlugin().iosInfo;
SCGlobalConfig.model = dev.model;
SCGlobalConfig.sysVersion = dev.systemVersion;
SCGlobalConfig.applyDevicePerformanceProfile(
isLowRamDevice: false,
processorCount: processorCount,
);
_logPerformanceProfile();
}
}
static void _logPerformanceProfile() {
debugPrint(
'[GiftFx][device] model=${SCGlobalConfig.model} '
'sdk=${SCGlobalConfig.sdkInt} processors=${SCGlobalConfig.processorCount} '
'lowRam=${SCGlobalConfig.isLowRamDevice} '
'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice} '
'allowsHighCost=${SCGlobalConfig.allowsHighCostAnimations}',
);
}
// ID
static Future<String> getDeviceId({bool forceRefresh = false}) async {
//
if (forceRefresh || SCGlobalConfig.imei.isEmpty) {
final newId = await _g();
DataPersistence.setUniqueId(newId);
SCGlobalConfig.imei = newId;
return newId;
}
// ID
String storedId = DataPersistence.getUniqueId();
if (storedId.isNotEmpty) {
SCGlobalConfig.imei = storedId;
return storedId;
}
// ID并存储
final newId = await _g();
DataPersistence.setUniqueId(newId);
SCGlobalConfig.imei = newId;
return newId;
}
// ID
static Future<String> _g() async {
final packageInfo = await PackageInfo.fromPlatform();
// UUID
final newId = '${const Uuid().v4()}-${packageInfo.packageName}';
DataPersistence.setUniqueId(newId);
return newId;
}
}

View File

@ -9,7 +9,7 @@ import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
import 'package:tancent_vap/utils/constant.dart';
import 'package:tancent_vap/widgets/vap_view.dart';
import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
@ -40,7 +40,7 @@ class SCGiftVapSvgaManager {
final SCPriorityQueue<SCVapTask> _entryQueue = SCPriorityQueue<SCVapTask>(
(a, b) => a.compareTo(b), // SCVapTask compareTo
);
VapController? _rgc;
SCGiftMp4Controller? _mp4Controller;
SVGAAnimationController? _rsc;
bool _play = false;
bool _dis = false;
@ -62,9 +62,9 @@ class SCGiftVapSvgaManager {
void setMute(bool muteMusic) {
_mute = muteMusic;
_rsc?.muted = _mute;
final vapController = _rgc;
if (vapController != null) {
unawaited(vapController.setMute(_mute));
final mp4Controller = _mp4Controller;
if (mp4Controller != null) {
unawaited(mp4Controller.setMute(_mute));
}
unawaited(DataPersistence.setPlayGiftMusic(_mute));
}
@ -73,12 +73,23 @@ class SCGiftVapSvgaManager {
return _mute;
}
void _log(String message) {}
void _log(String message) {
debugPrint('[GiftFx][manager] $message');
}
bool _needsSvgaController(String path) {
return SCPathUtils.getFileExtension(path).toLowerCase() == ".svga";
}
bool _needsMp4Controller(String path) {
final ext = SCPathUtils.getFileExtension(path).toLowerCase();
return ext == ".mp4" || ext == ".vap";
}
bool _isSupportedEffectPath(String path) {
return _needsSvgaController(path) || _needsMp4Controller(path);
}
static int resolveEffectPriority({
int priority = 0,
int type = giftEffectType,
@ -103,7 +114,7 @@ class SCGiftVapSvgaManager {
if (_needsSvgaController(task.path)) {
return _rsc != null;
}
return _rgc != null;
return _mp4Controller != null;
}
bool _isPlayableFileReady(String path) {
@ -133,6 +144,13 @@ class SCGiftVapSvgaManager {
_isPreloadedOrLoading(path)) {
return;
}
if (!_isSupportedEffectPath(path)) {
_log(
'preload ignored because effect file extension is unsupported '
'path=$path ext=${SCPathUtils.getFileExtension(path)}',
);
return;
}
if (highPriority) {
_log('high priority preload path=$path');
await _warmupPath(path);
@ -434,27 +452,25 @@ class SCGiftVapSvgaManager {
}
//
void bindVapCtrl(VapController vapController) {
void bindMp4Ctrl(SCGiftMp4Controller mp4Controller) {
_mute = DataPersistence.getPlayGiftMusic();
_dis = false;
_rgc = vapController;
unawaited(vapController.setMute(_mute));
_mp4Controller = mp4Controller;
unawaited(mp4Controller.setMute(_mute));
_log(
'bindVapCtrl hasVapCtrl=${_rgc != null} '
'bindMp4Ctrl hasMp4Ctrl=${_mp4Controller != null} '
'hasSvgaCtrl=${_rsc != null} queue=$_queueSummary mute=$_mute',
);
_rgc?.setAnimListener(
onVideoStart: () {
_log('vap onVideoStart path=${_currentTask?.path}');
_mp4Controller?.setPlaybackListener(
onStart: () {
_log('mp4 onVideoStart path=${_currentTask?.path}');
},
onVideoComplete: () {
_log('vap onVideoComplete path=${_currentTask?.path}');
onComplete: () {
_log('mp4 onVideoComplete path=${_currentTask?.path}');
_hcs();
},
onFailed: (code, type, msg) {
_log(
'vap onFailed path=${_currentTask?.path} code=$code type=$type msg=$msg',
);
onFailed: (error) {
_log('mp4 onFailed path=${_currentTask?.path} error=$error');
_hcs();
},
);
@ -469,7 +485,7 @@ class SCGiftVapSvgaManager {
_rsc?.muted = _mute;
_log(
'bindSvgaCtrl hasSvgaCtrl=${_rsc != null} '
'hasVapCtrl=${_rgc != null} queue=$_queueSummary',
'hasMp4Ctrl=${_mp4Controller != null} queue=$_queueSummary',
);
_rsc?.addStatusListener((AnimationStatus status) {
if (status.isCompleted) {
@ -493,6 +509,13 @@ class SCGiftVapSvgaManager {
_log('play ignored because path is empty');
return;
}
if (!_isSupportedEffectPath(path)) {
_log(
'play ignored because effect file extension is unsupported '
'path=$path ext=${SCPathUtils.getFileExtension(path)}',
);
return;
}
final resolvedPriority = resolveEffectPriority(
priority: priority,
type: type,
@ -503,12 +526,13 @@ class SCGiftVapSvgaManager {
'sdkInt=${SCGlobalConfig.sdkInt} maxSdkNoAnim=${SCGlobalConfig.maxSdkNoAnim} '
'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice} '
'disposed=$_dis playing=$_play queueBefore=$_queueSummary '
'hasSvgaCtrl=${_rsc != null} hasVapCtrl=${_rgc != null}',
'hasSvgaCtrl=${_rsc != null} hasMp4Ctrl=${_mp4Controller != null}',
);
if (SCGlobalConfig.allowsHighCostAnimations) {
if (_dis) {
_log('play ignored because manager is disposed path=$path');
return;
_log(
'manager disposed/no controller; queue play until bind path=$path',
);
}
final task = SCVapTask(
path: path,
@ -546,7 +570,7 @@ class SCGiftVapSvgaManager {
_log(
'controller not ready for path=${task?.path} '
'needSvga=${task != null ? _needsSvgaController(task.path) : "unknown"} '
'hasSvgaCtrl=${_rsc != null} hasVapCtrl=${_rgc != null} '
'hasSvgaCtrl=${_rsc != null} hasMp4Ctrl=${_mp4Controller != null} '
'queue=$_queueSummary',
);
return;
@ -596,51 +620,47 @@ class SCGiftVapSvgaManager {
_log('forward asset svga path=${task.path}');
} else {
_log('play asset vap/mp4 path=${task.path}');
if (task.customResources != null) {
task.customResources?.forEach((k, v) async {
await _rgc?.setVapTagContent(k, v);
});
}
await _rgc?.playAsset(task.path);
await _mp4Controller?.playAsset(task.path);
}
}
//
Future<void> _pf(SCVapTask task) async {
Future<void> _pf(SCVapTask task, {String? playablePath}) async {
if (_dis) {
return;
}
final resolvedPath = playablePath ?? task.path;
if (SCPathUtils.getFileExtension(task.path).toLowerCase() == ".svga") {
final entity = await _loadSvgaEntity(task.path);
final entity = await _loadSvgaEntity(resolvedPath);
if (!_isCurrentTask(task)) {
return;
}
_log('play local svga file path=${task.path}');
_log('play local svga file path=$resolvedPath source=${task.path}');
_rsc?.muted = _mute;
_rsc?.videoItem = entity;
_rsc?.reset();
_rsc?.forward();
return;
}
if (_rgc == null) {
_log('skip playFile because vap controller is null path=${task.path}');
if (!_needsMp4Controller(task.path) && !_needsMp4Controller(resolvedPath)) {
_log(
'skip playFile because effect file extension is unsupported '
'path=$resolvedPath source=${task.path}',
);
_finishCurrentTask();
return;
}
_log('play local vap/mp4 file path=${task.path}');
await _rgc?.setMute(_mute);
if (_mp4Controller == null) {
_log('skip playFile because mp4 controller is null path=$resolvedPath');
_finishCurrentTask();
return;
}
_log('play local vap/mp4 file path=$resolvedPath source=${task.path}');
await _mp4Controller?.setMute(_mute);
if (!_isCurrentTask(task)) {
return;
}
if (task.customResources != null) {
task.customResources?.forEach((k, v) async {
await _rgc?.setVapTagContent(k, v);
});
}
if (!_isCurrentTask(task)) {
return;
}
await _rgc!.playFile(task.path);
await _mp4Controller!.playFile(resolvedPath);
}
//
@ -672,22 +692,15 @@ class SCGiftVapSvgaManager {
}
if (!_dis) {
_log('use prepared network vap/mp4 local path=$playablePath');
await _pf(
SCVapTask(
path: playablePath,
type: task.type,
priority: task.priority,
customResources: task.customResources,
),
);
await _pf(task, playablePath: playablePath);
}
}
}
//
void _hcs() {
if (_rgc != null && !_dis) {
_log('finish vap task path=${_currentTask?.path}');
if (_mp4Controller != null && !_dis) {
_log('finish mp4 task path=${_currentTask?.path}');
_finishCurrentTask(delay: const Duration(milliseconds: 50));
}
}
@ -703,7 +716,7 @@ class SCGiftVapSvgaManager {
return;
}
_log('task watchdog timeout path=${task.path}');
_rgc?.stop();
_mp4Controller?.stop();
_rsc?.stop();
_rsc?.reset();
_rsc?.videoItem = null;
@ -741,7 +754,7 @@ class SCGiftVapSvgaManager {
_preloadQueue.clear();
_queuedPreloadPaths.clear();
_activePreloadCount = 0;
_rgc?.stop();
_mp4Controller?.stop();
_rsc?.stop();
_rsc?.reset();
_rsc?.videoItem = null;
@ -790,8 +803,8 @@ class SCGiftVapSvgaManager {
_playablePathTasks.clear();
clearMemoryCache(includeCurrent: true);
_playablePathCache.clear();
_rgc?.dispose();
_rgc = null;
_mp4Controller?.dispose();
_mp4Controller = null;
_rsc?.dispose();
_rsc = null;
}

View File

@ -6,29 +6,39 @@ import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
enum SCUserBadgeDisplayScope { short, long, all }
class SCUserBadgeStrip extends StatefulWidget {
const SCUserBadgeStrip({
super.key,
this.userId,
this.fallbackBadges = const <WearBadge>[],
this.displayScope = SCUserBadgeDisplayScope.short,
this.height,
this.badgeHeight,
this.longBadgeWidth,
this.spacing,
this.runSpacing,
this.maxBadges,
this.wrap = false,
this.maxWrapRows,
this.scrollLastWrapRow = false,
this.reserveSpace = false,
this.alignment = WrapAlignment.center,
});
final String? userId;
final List<WearBadge> fallbackBadges;
final SCUserBadgeDisplayScope displayScope;
final double? height;
final double? badgeHeight;
final double? longBadgeWidth;
final double? spacing;
final double? runSpacing;
final int? maxBadges;
final bool wrap;
final int? maxWrapRows;
final bool scrollLastWrapRow;
final bool reserveSpace;
final WrapAlignment alignment;
@ -100,20 +110,48 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
return FutureBuilder<SCUserBadgeRes>(
future: future,
builder: (context, snapshot) {
final remoteBadges =
snapshot.data?.displayBadges ?? const <WearBadge>[];
final badges = remoteBadges.isNotEmpty ? remoteBadges : _fallbackBadges;
return _buildBadges(badges, stripHeight);
if (snapshot.hasData) {
return _buildBadges(_remoteBadges(snapshot.data), stripHeight);
}
return _buildBadges(_fallbackBadges, stripHeight);
},
);
}
List<WearBadge> get _fallbackBadges {
return widget.fallbackBadges
.where(_matchesDisplayScope)
.where((badge) => _badgeUrl(badge).isNotEmpty)
.toList();
}
List<WearBadge> _remoteBadges(SCUserBadgeRes? data) {
if (data == null) {
return const <WearBadge>[];
}
switch (widget.displayScope) {
case SCUserBadgeDisplayScope.long:
return data.longDisplayBadges;
case SCUserBadgeDisplayScope.all:
return data.allBadges
.where((badge) => _badgeUrl(badge).isNotEmpty)
.toList();
case SCUserBadgeDisplayScope.short:
return data.shortDisplayBadges;
}
}
bool _matchesDisplayScope(WearBadge badge) {
switch (widget.displayScope) {
case SCUserBadgeDisplayScope.long:
return SCUserBadgeRes.isLongDisplayBadge(badge);
case SCUserBadgeDisplayScope.all:
return true;
case SCUserBadgeDisplayScope.short:
return SCUserBadgeRes.isShortDisplayBadge(badge);
}
}
Widget _buildBadges(List<WearBadge> rawBadges, double stripHeight) {
final badges =
widget.maxBadges == null
@ -127,19 +165,29 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
final badgeHeight = widget.badgeHeight ?? stripHeight;
final spacing = widget.spacing ?? 5.w;
final runSpacing = widget.runSpacing ?? 5.w;
final children =
badges
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
.toList();
if (widget.wrap) {
if (widget.maxWrapRows != null) {
return _buildLimitedWrapBadges(
badges,
stripHeight: stripHeight,
badgeHeight: badgeHeight,
spacing: spacing,
runSpacing: runSpacing,
);
}
return SizedBox(
height: stripHeight,
child: Center(
child: Wrap(
alignment: widget.alignment,
spacing: spacing,
runSpacing: 5.w,
runSpacing: runSpacing,
children: children,
),
),
@ -156,14 +204,138 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
);
}
Widget _buildLimitedWrapBadges(
List<WearBadge> badges, {
required double stripHeight,
required double badgeHeight,
required double spacing,
required double runSpacing,
}) {
final maxRows = widget.maxWrapRows ?? 1;
if (maxRows <= 1) {
return _buildScrollableBadgeRow(
badges,
stripHeight: stripHeight,
badgeHeight: badgeHeight,
spacing: spacing,
);
}
return SizedBox(
height: stripHeight,
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth =
constraints.maxWidth.isFinite
? constraints.maxWidth
: _badgesTotalWidth(badges, badgeHeight, spacing);
final widestBadge = badges
.map((badge) => _badgeWidth(badge, badgeHeight))
.fold<double>(
0,
(current, width) => width > current ? width : current,
);
final perRow =
widestBadge <= 0
? badges.length
: ((maxWidth + spacing) / (widestBadge + spacing))
.floor()
.clamp(1, badges.length);
final firstRows = maxRows - 1;
final fixedCount = (perRow * firstRows).clamp(0, badges.length);
final fixedBadges = badges.take(fixedCount).toList();
final lastRowBadges = badges.skip(fixedCount).toList();
final rows = <Widget>[];
for (var start = 0; start < fixedBadges.length; start += perRow) {
final end =
start + perRow > fixedBadges.length
? fixedBadges.length
: start + perRow;
rows.add(
_buildBadgeRow(
fixedBadges.sublist(start, end),
badgeHeight: badgeHeight,
spacing: spacing,
),
);
}
if (lastRowBadges.isNotEmpty) {
rows.add(
widget.scrollLastWrapRow
? _buildScrollableBadgeRow(
lastRowBadges,
stripHeight: badgeHeight,
badgeHeight: badgeHeight,
spacing: spacing,
)
: _buildBadgeRow(
lastRowBadges.take(perRow).toList(),
badgeHeight: badgeHeight,
spacing: spacing,
),
);
}
if (rows.isEmpty) {
return const SizedBox.shrink();
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: _withVerticalSpacing(rows, runSpacing),
);
},
),
);
}
Widget _buildBadgeRow(
List<WearBadge> badges, {
required double badgeHeight,
required double spacing,
}) {
return SizedBox(
height: badgeHeight,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: _rowAlignment,
children: _withSpacing(
badges
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
.toList(),
spacing,
),
),
);
}
Widget _buildScrollableBadgeRow(
List<WearBadge> badges, {
required double stripHeight,
required double badgeHeight,
required double spacing,
}) {
return SizedBox(
height: stripHeight,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: _withSpacing(
badges
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
.toList(),
spacing,
),
),
),
);
}
Widget _buildBadge(WearBadge badge, {required double badgeHeight}) {
final type = badge.type?.trim().toUpperCase() ?? "";
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
final isLongBadge = type == "LONG" || type == "VIP" || sourceType == "VIP";
final width =
isLongBadge
? (widget.longBadgeWidth ?? badgeHeight * 2.2)
: badgeHeight;
final width = _badgeWidth(badge, badgeHeight);
return netImage(
url: _badgeUrl(badge),
width: width,
@ -175,6 +347,47 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
);
}
double _badgeWidth(WearBadge badge, double badgeHeight) {
final type = badge.type?.trim().toUpperCase() ?? "";
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
final isLongBadge = type == "LONG" || type == "VIP" || sourceType == "VIP";
return isLongBadge
? (widget.longBadgeWidth ?? badgeHeight * 2.2)
: badgeHeight;
}
double _badgesTotalWidth(
List<WearBadge> badges,
double badgeHeight,
double spacing,
) {
if (badges.isEmpty) {
return 0;
}
final badgeWidth = badges.fold<double>(
0,
(sum, badge) => sum + _badgeWidth(badge, badgeHeight),
);
return badgeWidth + spacing * (badges.length - 1);
}
MainAxisAlignment get _rowAlignment {
switch (widget.alignment) {
case WrapAlignment.start:
return MainAxisAlignment.start;
case WrapAlignment.end:
return MainAxisAlignment.end;
case WrapAlignment.spaceBetween:
return MainAxisAlignment.spaceBetween;
case WrapAlignment.spaceAround:
return MainAxisAlignment.spaceAround;
case WrapAlignment.spaceEvenly:
return MainAxisAlignment.spaceEvenly;
case WrapAlignment.center:
return MainAxisAlignment.center;
}
}
String _badgeUrl(WearBadge badge) {
return (badge.selectUrl ?? "").trim();
}
@ -192,6 +405,20 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
}
return spaced;
}
List<Widget> _withVerticalSpacing(List<Widget> children, double spacing) {
if (children.length < 2) {
return children;
}
final spaced = <Widget>[];
for (var i = 0; i < children.length; i++) {
if (i > 0) {
spaced.add(SizedBox(height: spacing));
}
spaced.add(children[i]);
}
return spaced;
}
}
class _BadgeFutureCacheEntry {

View File

@ -0,0 +1,350 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
enum SCGiftMp4SourceType { asset, file }
typedef SCGiftMp4Callback = void Function();
typedef SCGiftMp4FailedCallback = void Function(Object? error);
class SCGiftMp4PlayRequest {
const SCGiftMp4PlayRequest({
required this.path,
required this.sourceType,
required this.token,
required this.muted,
});
final String path;
final SCGiftMp4SourceType sourceType;
final int token;
final bool muted;
}
class SCGiftMp4Controller extends ChangeNotifier {
static const MethodChannel _events = MethodChannel(
'com.org.yumiparty/gift_mp4_events',
);
static const MethodChannel _control = MethodChannel(
'com.org.yumiparty/gift_mp4_control',
);
static final Map<int, SCGiftMp4Controller> _controllersByViewId = {};
static bool _eventsHandlerInstalled = false;
SCGiftMp4Controller() {
_installEventsHandler();
}
SCGiftMp4PlayRequest? _request;
SCGiftMp4Callback? _onStart;
SCGiftMp4Callback? _onComplete;
SCGiftMp4FailedCallback? _onFailed;
int _nextToken = 0;
int? _activeViewId;
bool _muted = false;
bool _disposed = false;
SCGiftMp4PlayRequest? get request => _request;
static void _installEventsHandler() {
if (_eventsHandlerInstalled) {
return;
}
_eventsHandlerInstalled = true;
_events.setMethodCallHandler(_dispatchNativeEvent);
}
static Future<void> _dispatchNativeEvent(MethodCall call) async {
final args = call.arguments;
if (args is! Map) {
return;
}
final viewId = args['viewId'];
if (viewId is! int) {
return;
}
await _controllersByViewId[viewId]?._handleNativeEvent(call);
}
void setPlaybackListener({
SCGiftMp4Callback? onStart,
SCGiftMp4Callback? onComplete,
SCGiftMp4FailedCallback? onFailed,
}) {
_onStart = onStart;
_onComplete = onComplete;
_onFailed = onFailed;
}
Future<void> setMute(bool muted) async {
_muted = muted;
final viewId = _activeViewId;
if (viewId == null) {
return;
}
try {
await _control.invokeMethod<void>('setMute', {
'viewId': viewId,
'muted': muted,
});
} catch (_) {}
}
Future<void> playAsset(String path) =>
_play(path: path, sourceType: SCGiftMp4SourceType.asset);
Future<void> playFile(String path) =>
_play(path: path, sourceType: SCGiftMp4SourceType.file);
Future<void> _play({
required String path,
required SCGiftMp4SourceType sourceType,
}) async {
if (_disposed || path.isEmpty) {
debugPrint(
'[GiftFx][mp4] ignore play disposed=$_disposed pathEmpty=${path.isEmpty}',
);
return;
}
_unregisterActiveView();
_activeViewId = null;
_request = SCGiftMp4PlayRequest(
path: path,
sourceType: sourceType,
token: ++_nextToken,
muted: _muted,
);
debugPrint(
'[GiftFx][mp4] request token=$_nextToken source=${sourceType.name} path=$path',
);
notifyListeners();
}
void attachViewId(int viewId, int token) {
if (_request?.token != token || _disposed) {
debugPrint(
'[GiftFx][mp4] ignore attach viewId=$viewId token=$token '
'activeToken=${_request?.token} disposed=$_disposed',
);
return;
}
_unregisterActiveView();
_activeViewId = viewId;
_controllersByViewId[viewId] = this;
debugPrint('[GiftFx][mp4] attach viewId=$viewId token=$token');
unawaited(setMute(_muted));
}
void stop() {
final viewId = _activeViewId;
_request = null;
_unregisterActiveView();
_activeViewId = null;
_nextToken++;
if (viewId != null) {
unawaited(
_control
.invokeMethod<void>('stop', {'viewId': viewId})
.catchError((_) {}),
);
}
if (!_disposed) {
notifyListeners();
}
}
void _unregisterActiveView() {
final viewId = _activeViewId;
if (viewId == null) {
return;
}
if (identical(_controllersByViewId[viewId], this)) {
_controllersByViewId.remove(viewId);
}
}
Future<void> _handleNativeEvent(MethodCall call) async {
final args = call.arguments;
if (args is! Map) {
return;
}
final token = args['token'];
final request = _request;
if (request == null || token != request.token || _disposed) {
return;
}
switch (call.method) {
case 'started':
debugPrint('[GiftFx][mp4] native started token=$token');
_onStart?.call();
break;
case 'completed':
debugPrint('[GiftFx][mp4] native completed token=$token');
_request = null;
_unregisterActiveView();
_activeViewId = null;
notifyListeners();
_onComplete?.call();
break;
case 'failed':
final error = args['message'] ?? args['code'];
debugPrint('[GiftFx][mp4] native failed token=$token error=$error');
_request = null;
_unregisterActiveView();
_activeViewId = null;
notifyListeners();
_onFailed?.call(error);
break;
}
}
@override
void dispose() {
_disposed = true;
stop();
super.dispose();
}
}
class GiftMp4EffectView extends StatefulWidget {
const GiftMp4EffectView({super.key, required this.controller});
final SCGiftMp4Controller controller;
@override
State<GiftMp4EffectView> createState() => _GiftMp4EffectViewState();
}
class _GiftMp4EffectViewState extends State<GiftMp4EffectView> {
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final request = widget.controller.request;
if (request == null) {
return const SizedBox.shrink();
}
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return AndroidView(
key: ValueKey('gift-mp4-${request.token}-${request.path}'),
viewType: 'gift_mp4_video_view',
creationParams: <String, Object>{
'path': request.path,
'sourceType': request.sourceType.name,
'token': request.token,
'muted': request.muted,
'renderMode': 'auto',
},
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated:
(viewId) =>
widget.controller.attachViewId(viewId, request.token),
);
}
return _FallbackMp4EffectVideo(
request: request,
onCompleted: widget.controller.stop,
);
},
);
}
}
class _FallbackMp4EffectVideo extends StatefulWidget {
const _FallbackMp4EffectVideo({
required this.request,
required this.onCompleted,
});
final SCGiftMp4PlayRequest request;
final VoidCallback onCompleted;
@override
State<_FallbackMp4EffectVideo> createState() =>
_FallbackMp4EffectVideoState();
}
class _FallbackMp4EffectVideoState extends State<_FallbackMp4EffectVideo> {
VideoPlayerController? _controller;
@override
void initState() {
super.initState();
_prepare();
}
@override
void didUpdateWidget(covariant _FallbackMp4EffectVideo oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.request.token != widget.request.token) {
_disposeController();
_prepare();
}
}
Future<void> _prepare() async {
final controller =
widget.request.sourceType == SCGiftMp4SourceType.asset
? VideoPlayerController.asset(widget.request.path)
: VideoPlayerController.file(File(widget.request.path));
_controller = controller;
controller.addListener(_handleState);
await controller.setLooping(false);
await controller.setVolume(widget.request.muted ? 0 : 1);
await controller.initialize();
if (!mounted || _controller != controller) {
return;
}
await controller.play();
if (mounted) {
setState(() {});
}
}
void _handleState() {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) {
return;
}
final duration = controller.value.duration;
if (duration != Duration.zero && controller.value.position >= duration) {
widget.onCompleted();
}
}
void _disposeController() {
final controller = _controller;
if (controller == null) {
return;
}
controller.removeListener(_handleState);
controller.dispose();
_controller = null;
}
@override
void dispose() {
_disposeController();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) {
return const SizedBox.shrink();
}
return Center(
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
),
);
}
}

View File

@ -1,73 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_svga/flutter_svga.dart';
import 'package:tancent_vap/utils/constant.dart';
import 'package:tancent_vap/widgets/vap_view.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
class VapPlusSvgaPlayer extends StatefulWidget {
final String tag;
const VapPlusSvgaPlayer({super.key, required this.tag});
@override
State<VapPlusSvgaPlayer> createState() => _VapPlusSvgaPlayerState();
}
class _VapPlusSvgaPlayerState extends State<VapPlusSvgaPlayer>
with TickerProviderStateMixin {
late SVGAAnimationController _svgaController;
void _giftFxLog(String message) {}
@override
void initState() {
super.initState();
_svgaController = SVGAAnimationController(vsync: this);
_giftFxLog('initState tag=${widget.tag}');
if (widget.tag == "room_gift") {
SCGiftVapSvgaManager().bindSvgaCtrl(_svgaController);
}
}
@override
void dispose() {
_giftFxLog('dispose tag=${widget.tag}');
SCGiftVapSvgaManager().dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: RepaintBoundary(
child: Stack(
children: [
Positioned.fill(
child: RepaintBoundary(
child: VapView(
scaleType: ScaleType.fitCenter,
onViewCreated: (controller) {
_giftFxLog('onViewCreated tag=${widget.tag}');
if (widget.tag == "room_gift") {
SCGiftVapSvgaManager().bindVapCtrl(controller);
// VapManager().play("https://res.cloudinary.com/dkmchpua1/video/upload/v1737624783/vcg9co6yyfqsadgety1n.mp4");
}
},
),
),
),
Positioned.fill(
child: RepaintBoundary(
child: SVGAImage(
_svgaController,
fit: BoxFit.contain,
allowDrawingOverflow: false,
),
),
),
],
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_svga/flutter_svga.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart';
class VapPlusSvgaPlayer extends StatefulWidget {
final String tag;
const VapPlusSvgaPlayer({super.key, required this.tag});
@override
State<VapPlusSvgaPlayer> createState() => _VapPlusSvgaPlayerState();
}
class _VapPlusSvgaPlayerState extends State<VapPlusSvgaPlayer>
with TickerProviderStateMixin {
late SVGAAnimationController _svgaController;
late SCGiftMp4Controller _mp4Controller;
void _giftFxLog(String message) {
debugPrint('[GiftFx][host] $message');
}
@override
void initState() {
super.initState();
_svgaController = SVGAAnimationController(vsync: this);
_mp4Controller = SCGiftMp4Controller();
_giftFxLog('initState tag=${widget.tag}');
if (widget.tag == "room_gift") {
SCGiftVapSvgaManager().bindSvgaCtrl(_svgaController);
SCGiftVapSvgaManager().bindMp4Ctrl(_mp4Controller);
}
}
@override
void dispose() {
_giftFxLog('dispose tag=${widget.tag}');
SCGiftVapSvgaManager().dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: RepaintBoundary(
child: Stack(
children: [
Positioned.fill(
child: RepaintBoundary(
child: GiftMp4EffectView(controller: _mp4Controller),
),
),
Positioned.fill(
child: RepaintBoundary(
child: SVGAImage(
_svgaController,
fit: BoxFit.contain,
allowDrawingOverflow: false,
),
),
),
],
),
),
);
}
}

View File

@ -1303,16 +1303,40 @@ class _MsgItemState extends State<MsgItem> {
child: SCUserBadgeStrip(
userId: userId,
fallbackBadges: fallbackBadges,
height: 23.w,
badgeHeight: 21.w,
height: 28.w,
badgeHeight: 25.w,
longBadgeWidth: 51.w,
spacing: 4.5.w,
spacing: 5.w,
maxBadges: 8,
reserveSpace: true,
),
);
}
Widget _buildLongMedals({
required String? userId,
List<WearBadge> fallbackBadges = const <WearBadge>[],
}) {
final hasUserId = (userId ?? "").trim().isNotEmpty;
if (!hasUserId && fallbackBadges.isEmpty) {
return const SizedBox.shrink();
}
return SizedBox(
width: double.infinity,
child: SCUserBadgeStrip(
userId: userId,
fallbackBadges: fallbackBadges,
displayScope: SCUserBadgeDisplayScope.long,
height: 18.w,
badgeHeight: 16.w,
longBadgeWidth: 50.w,
spacing: 3.w,
maxBadges: 8,
reserveSpace: false,
),
);
}
int _msgColor(String type) {
switch (type) {
case SCRoomMsgType.joinRoom:
@ -1851,13 +1875,18 @@ class _MsgItemState extends State<MsgItem> {
SizedBox(width: 8.w),
Expanded(
child: SizedBox(
height: 50.w,
height: 76.w,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildUserNameAndLevels(widget.msg.user),
SizedBox(height: 2.w),
_buildLongMedals(
userId: widget.msg.user?.id,
fallbackBadges: _activeWearBadges(widget.msg.user),
),
SizedBox(height: 2.w),
_buildMedals(
userId: widget.msg.user?.id,
fallbackBadges: _activeWearBadges(widget.msg.user),
@ -1897,13 +1926,18 @@ class _MsgItemState extends State<MsgItem> {
SizedBox(width: 8.w),
Expanded(
child: SizedBox(
height: 50.w,
height: 76.w,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildUserNameAndLevels(user),
SizedBox(height: 2.w),
_buildLongMedals(
userId: user.id,
fallbackBadges: _activeWearBadges(user),
),
SizedBox(height: 2.w),
_buildMedals(
userId: user.id,
fallbackBadges: _activeWearBadges(user),

View File

@ -282,13 +282,13 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
mainAxisSize: MainAxisSize.min,
children: [
_buildProfileHeader(context, profile, ref),
SizedBox(height: 6.w),
SizedBox(height: 4.w),
_buildBadgeStrip(
profile?.id ?? widget.userId,
ref,
profile,
),
SizedBox(height: 8.w),
SizedBox(height: 5.w),
_buildActions(
context: context,
ref: ref,
@ -422,15 +422,17 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
),
),
SizedBox(height: 5.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.w,
runSpacing: 5.w,
children: [
msgRoleTag(
ref.userCardInfo?.roomRole ?? "",
width: 16.w,
height: 16.w,
),
SizedBox(width: 4.w),
socialchatNickNameText(
maxWidth: 116.w,
profile?.userNickname ?? "",
@ -440,13 +442,11 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
type: "",
needScroll: (profile?.userNickname?.characters.length ?? 0) > 12,
),
SizedBox(width: 3.w),
getVIPBadge(
profile?.vipLevel ?? profile?.getVIP()?.name,
width: 45.w,
height: 25.w,
),
SizedBox(width: 3.w),
getWealthLevel(
profile?.wealthLevel ??
ref.userCardInfo?.userLevel?.wealthLevel ??
@ -455,7 +455,6 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
height: 20.w,
fontSize: 9.sp,
),
SizedBox(width: 2.w),
getUserLevel(
profile?.charmLevel ??
ref.userCardInfo?.userLevel?.charmLevel ??
@ -466,6 +465,8 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
),
],
),
SizedBox(height: 3.w),
_buildHeaderLongBadgeStrip(profile?.id ?? widget.userId, ref, profile),
SizedBox(height: 2.w),
_buildSpecialIdMetaRow(context, profile),
],
@ -552,21 +553,55 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
return xb(profile?.userSex, height: 15.w, color: null);
}
Widget _buildHeaderLongBadgeStrip(
String? userId,
SocialChatUserProfileManager ref,
SocialChatUserProfile? profile,
) {
return SizedBox(
width: double.infinity,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: SCUserBadgeStrip(
userId: userId,
fallbackBadges: _roomCardFallbackBadges(ref, profile),
displayScope: SCUserBadgeDisplayScope.long,
height: 62.w,
badgeHeight: 20.w,
longBadgeWidth: 62.w,
spacing: 4.w,
runSpacing: 4.w,
wrap: true,
maxWrapRows: 2,
scrollLastWrapRow: true,
reserveSpace: false,
),
),
);
}
Widget _buildBadgeStrip(
String? userId,
SocialChatUserProfileManager ref,
SocialChatUserProfile? profile,
) {
return SCUserBadgeStrip(
userId: userId,
fallbackBadges: _roomCardFallbackBadges(ref, profile),
height: 70.w,
badgeHeight: 57.5.w,
longBadgeWidth: 145.w,
spacing: 20.w,
maxBadges: 12,
wrap: false,
reserveSpace: true,
return SizedBox(
width: double.infinity,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: SCUserBadgeStrip(
userId: userId,
fallbackBadges: _roomCardFallbackBadges(ref, profile),
displayScope: SCUserBadgeDisplayScope.short,
height: 62.w,
badgeHeight: 57.5.w,
longBadgeWidth: 145.w,
spacing: 12.w,
maxBadges: 12,
wrap: false,
reserveSpace: false,
),
),
);
}

View File

@ -1,6 +1,21 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart'
show PropsResources;
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/ui_kit/widgets/props/sc_data_card_resource_view.dart';
import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart';
const Duration _kStoreBagSkeletonAnimationDuration = Duration(
milliseconds: 1450,
@ -10,6 +25,51 @@ const Color _kStoreBagSkeletonShellStrong = Color(0xFF1A4A41);
const Color _kStoreBagSkeletonBoneBase = Color(0xFF2B5C53);
const Color _kStoreBagSkeletonBoneHighlight = Color(0xFF5F8177);
const Color _kStoreBagSkeletonBorder = Color(0x66D8B57B);
const String _kStoreItemPreviewDialogTag = "storeItemFullScreenPreview";
enum SCStoreItemPreviewKind { resource, avatarFrame, dataCard }
void showStoreBagFullScreenResourcePreview(
BuildContext context, {
PropsResources? resource,
String? sourceUrl,
String? coverUrl,
SCStoreItemPreviewKind previewKind = SCStoreItemPreviewKind.resource,
BoxFit fit = BoxFit.contain,
}) {
final source = _firstNonBlank([sourceUrl, resource?.sourceUrl]);
final cover = _firstNonBlank([coverUrl, resource?.cover]);
if (source.isEmpty && cover.isEmpty && resource == null) {
return;
}
SmartDialog.show(
tag: _kStoreItemPreviewDialogTag,
alignment: Alignment.center,
animationType: SmartAnimationType.fade,
maskColor: Colors.transparent,
builder: (_) {
final size = MediaQuery.sizeOf(context);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => SmartDialog.dismiss(tag: _kStoreItemPreviewDialogTag),
child: Container(
width: size.width,
height: size.height,
color: Colors.black.withValues(alpha: 0.86),
child: IgnorePointer(
child: _StoreBagFullScreenPreview(
resource: resource,
sourceUrl: source,
coverUrl: cover,
previewKind: previewKind,
fit: fit,
),
),
),
);
},
);
}
Widget buildStoreBagItemTitle(
String content, {
@ -86,7 +146,7 @@ class SCStoreGridSkeleton extends StatelessWidget {
crossAxisCount: 3,
mainAxisSpacing: 12.w,
crossAxisSpacing: 12.w,
childAspectRatio: 0.78,
childAspectRatio: 0.88,
),
itemCount: itemCount,
itemBuilder: (context, index) => _buildStoreCardSkeleton(progress),
@ -95,6 +155,466 @@ class SCStoreGridSkeleton extends StatelessWidget {
}
}
class SCStoreGridItemCard extends StatelessWidget {
const SCStoreGridItemCard({
super.key,
required this.res,
required this.discount,
required this.onDetailsTap,
this.previewKind = SCStoreItemPreviewKind.resource,
this.coverWidth,
this.coverHeight,
this.previewWidth,
this.previewHeight,
this.coverFit = BoxFit.contain,
this.previewFit = BoxFit.contain,
this.coverBorderRadius,
});
final StoreListRes res;
final double discount;
final VoidCallback onDetailsTap;
final SCStoreItemPreviewKind previewKind;
final double? coverWidth;
final double? coverHeight;
final double? previewWidth;
final double? previewHeight;
final BoxFit coverFit;
final BoxFit previewFit;
final BorderRadius? coverBorderRadius;
@override
Widget build(BuildContext context) {
final firstPrice =
(res.propsPrices?.isNotEmpty ?? false) ? res.propsPrices!.first : null;
final amount = ((firstPrice?.amount ?? 0) * discount).toInt();
final tagText = _buildDurationTag(context, firstPrice?.days);
return GestureDetector(
onTap: onDetailsTap,
child: Container(
decoration: BoxDecoration(
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemBackgroundColor(),
border: Border.all(
color:
SCGlobalConfig.businessLogicStrategy
.getStoreItemUnselectedBorderColor(),
width: 1.w,
),
borderRadius: BorderRadius.all(Radius.circular(8.w)),
),
child: Stack(
children: [
PositionedDirectional(
top: 6.w,
start: 6.w,
child: _buildDurationBadge(tagText),
),
Padding(
padding: EdgeInsets.fromLTRB(6.w, 16.w, 6.w, 8.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildPreviewTapArea(context),
SizedBox(height: 8.w),
_buildPriceRow(amount, firstPrice),
],
),
),
],
),
),
);
}
Widget _buildPreviewTapArea(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showFullScreenPreview(context),
child: SizedBox(
width: double.infinity,
height:
previewHeight ??
coverHeight ??
(previewKind == SCStoreItemPreviewKind.avatarFrame ? 64.w : 62.w),
child: Center(child: _buildCover()),
),
);
}
void _showFullScreenPreview(BuildContext context) {
showStoreBagFullScreenResourcePreview(
context,
resource: res.propsResources,
previewKind: previewKind,
fit: previewFit,
);
}
Widget _buildCover() {
Widget child = netImage(
url: res.propsResources?.cover ?? "",
width: coverWidth ?? 58.w,
height: coverHeight ?? 58.w,
fit: coverFit,
);
if (coverBorderRadius != null) {
child = ClipRRect(borderRadius: coverBorderRadius!, child: child);
}
return child;
}
Widget _buildPriceRow(int amount, PropsPrices? firstPrice) {
return SizedBox(
width: double.infinity,
height: 22.w,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
SCGlobalConfig.businessLogicStrategy.getStoreItemGoldIcon(),
width: 18.w,
height: 18.w,
),
SizedBox(width: 4.w),
if (discount < 1) ...[
text(
"${firstPrice?.amount ?? 0}",
fontSize: 12.sp,
textColor:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
decoration: TextDecoration.lineThrough,
),
SizedBox(width: 4.w),
],
text(
"$amount",
fontSize: 13.sp,
textColor:
SCGlobalConfig.businessLogicStrategy
.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
),
],
),
),
);
}
Widget _buildDurationBadge(String tagText) {
if (tagText.isEmpty) {
return const SizedBox.shrink();
}
return Container(
constraints: BoxConstraints(maxWidth: 58.w),
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.w),
decoration: BoxDecoration(
color: const Color(0xff18F2B1).withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(4.w),
border: Border.all(
color: const Color(0xff18F2B1).withValues(alpha: 0.28),
width: 0.5.w,
),
),
child: text(
tagText,
fontSize: 9.sp,
textColor:
SCGlobalConfig.businessLogicStrategy.getStoreItemPriceTextColor(),
fontWeight: FontWeight.w600,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
}
String _buildDurationTag(BuildContext context, num? days) {
if (days == null) {
return "";
}
final normalizedDays = days % 1 == 0 ? days.toInt().toString() : "$days";
return "$normalizedDays ${SCAppLocalizations.of(context)!.day}";
}
}
class _StoreBagFullScreenPreview extends StatelessWidget {
const _StoreBagFullScreenPreview({
this.resource,
this.sourceUrl,
this.coverUrl,
required this.previewKind,
required this.fit,
});
final PropsResources? resource;
final String? sourceUrl;
final String? coverUrl;
final SCStoreItemPreviewKind previewKind;
final BoxFit fit;
@override
Widget build(BuildContext context) {
final source = _firstNonBlank([sourceUrl, resource?.sourceUrl]);
if (previewKind == SCStoreItemPreviewKind.avatarFrame &&
!_isMp4Like(source)) {
final screenSize = MediaQuery.sizeOf(context);
return Center(
child: head(
url: AccountStorage().getCurrentUser()?.userProfile?.userAvatar ?? "",
width: screenSize.width * 0.58,
height: screenSize.width * 0.58,
headdress: source,
headdressCover: _firstNonBlank([coverUrl, resource?.cover]),
),
);
}
return _StoreBagFullScreenResourceView(
resource: resource,
sourceUrl: sourceUrl,
coverUrl: coverUrl,
fit: fit,
);
}
}
class _StoreBagFullScreenResourceView extends StatelessWidget {
const _StoreBagFullScreenResourceView({
this.resource,
this.sourceUrl,
this.coverUrl,
required this.fit,
});
final PropsResources? resource;
final String? sourceUrl;
final String? coverUrl;
final BoxFit fit;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = _resolvedPreviewSize(constraints.maxWidth);
final height = _resolvedPreviewSize(constraints.maxHeight);
final source = _firstNonBlank([sourceUrl, resource?.sourceUrl]);
final cover = _firstNonBlank([coverUrl, resource?.cover]);
final resourceUrl = source.isNotEmpty ? source : cover;
final fallback = _buildPreviewFallback(
cover,
width: width,
height: height,
fit: fit,
);
if (_isMp4Like(resourceUrl) &&
!kIsWeb &&
defaultTargetPlatform == TargetPlatform.android) {
return _StoreBagMp4FullScreenPreview(
sourceUrl: resourceUrl,
fallback: fallback,
loop: true,
);
}
return SCDataCardResourceView(
resource: resource,
sourceUrl: source.isNotEmpty ? source : resourceUrl,
coverUrl: cover,
width: width,
height: height,
fit: fit,
loop: true,
fallback: fallback,
allowCoverAsResource: true,
);
},
);
}
}
class _StoreBagMp4FullScreenPreview extends StatefulWidget {
const _StoreBagMp4FullScreenPreview({
required this.sourceUrl,
required this.fallback,
required this.loop,
});
final String sourceUrl;
final Widget fallback;
final bool loop;
@override
State<_StoreBagMp4FullScreenPreview> createState() =>
_StoreBagMp4FullScreenPreviewState();
}
class _StoreBagMp4FullScreenPreviewState
extends State<_StoreBagMp4FullScreenPreview> {
late final SCGiftMp4Controller _controller;
int _playToken = 0;
bool _started = false;
bool _failed = false;
@override
void initState() {
super.initState();
_controller = SCGiftMp4Controller();
_controller.setPlaybackListener(
onStart: () {
if (!mounted || _started) {
return;
}
setState(() => _started = true);
},
onComplete: _handleComplete,
onFailed: (_) => _markFailed(),
);
unawaited(_play());
}
@override
void didUpdateWidget(covariant _StoreBagMp4FullScreenPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.sourceUrl != widget.sourceUrl ||
oldWidget.loop != widget.loop) {
_playToken++;
_controller.stop();
setState(() {
_started = false;
_failed = false;
});
unawaited(_play());
}
}
Future<void> _play() async {
final source = widget.sourceUrl.trim();
if (source.isEmpty) {
_markFailed();
return;
}
final token = ++_playToken;
try {
final pathType = SCPathUtils.getPathType(source);
if (pathType == PathType.network) {
final file = await FileCacheManager.getInstance().getFile(url: source);
if (!_isCurrent(token)) {
return;
}
await _controller.setMute(true);
await _controller.playFile(file.path);
return;
}
if (pathType == PathType.asset) {
if (!_isCurrent(token)) {
return;
}
await _controller.setMute(true);
await _controller.playAsset(source);
return;
}
if (!_isCurrent(token)) {
return;
}
await _controller.setMute(true);
await _controller.playFile(_normalizeFilePath(source));
} catch (_) {
if (_isCurrent(token)) {
_markFailed();
}
}
}
void _handleComplete() {
if (!mounted || _failed || !widget.loop) {
return;
}
setState(() => _started = false);
unawaited(Future<void>.delayed(Duration.zero, _play));
}
bool _isCurrent(int token) => mounted && token == _playToken && !_failed;
void _markFailed() {
if (!mounted || _failed) {
return;
}
setState(() {
_failed = true;
_started = false;
});
}
String _normalizeFilePath(String path) {
final uri = Uri.tryParse(path);
if (uri != null && uri.scheme == 'file') {
return uri.toFilePath();
}
return path;
}
@override
void dispose() {
_playToken++;
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
if (!_started || _failed) widget.fallback,
if (!_failed) GiftMp4EffectView(controller: _controller),
],
);
}
}
Widget _buildPreviewFallback(
String coverUrl, {
double? width,
double? height,
required BoxFit fit,
}) {
if (coverUrl.trim().isEmpty) {
return SizedBox(width: width, height: height);
}
return netImage(
url: coverUrl.trim(),
width: width,
height: height,
fit: fit,
noDefaultImg: true,
);
}
String _firstNonBlank(Iterable<String?> values) {
for (final value in values) {
final text = value?.trim() ?? "";
if (text.isNotEmpty) {
return text;
}
}
return "";
}
bool _isMp4Like(String? url) {
final extension = SCPathUtils.getFileExtension(url ?? "").toLowerCase();
return extension == ".mp4" || extension == ".vap";
}
double? _resolvedPreviewSize(double value) {
return value.isFinite && value > 0 ? value : null;
}
class SCBagGridSkeleton extends StatelessWidget {
const SCBagGridSkeleton({
super.key,
@ -130,9 +650,18 @@ Widget _buildStoreCardSkeleton(double progress) {
return DecoratedBox(
decoration: _buildStoreBagShellDecoration(radius: 8.w),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.w),
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.w),
child: Column(
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: _buildStoreBagSkeletonBone(
progress,
width: 42.w,
height: 14.w,
borderRadius: BorderRadius.circular(4.w),
),
),
SizedBox(height: 2.w),
_buildStoreBagSkeletonBone(
progress,
@ -140,14 +669,7 @@ Widget _buildStoreCardSkeleton(double progress) {
height: 55.w,
borderRadius: BorderRadius.circular(12.w),
),
SizedBox(height: 10.w),
_buildStoreBagSkeletonBone(
progress,
width: double.infinity,
height: 12.w,
borderRadius: BorderRadius.circular(999.w),
),
SizedBox(height: 10.w),
SizedBox(height: 8.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -166,13 +688,6 @@ Widget _buildStoreCardSkeleton(double progress) {
),
],
),
SizedBox(height: 6.w),
_buildStoreBagSkeletonBone(
progress,
width: 48.w,
height: 11.w,
borderRadius: BorderRadius.circular(999.w),
),
],
),
),
@ -211,12 +726,13 @@ Widget _buildBagCardSkeleton(double progress, {required bool showTitle}) {
),
),
Column(
mainAxisSize: showTitle ? MainAxisSize.max : MainAxisSize.min,
children: [
SizedBox(height: 24.w),
if (showTitle) SizedBox(height: 24.w),
_buildStoreBagSkeletonBone(
progress,
width: 55.w,
height: 55.w,
width: 82.w,
height: 82.w,
borderRadius: BorderRadius.circular(12.w),
),
if (showTitle) ...[

File diff suppressed because it is too large Load Diff

View File

@ -32,11 +32,9 @@ void main() {
],
});
expect(result.displayBadges.map((badge) => badge.id), [
'vip',
'old',
'new',
]);
expect(result.allBadges.map((badge) => badge.id), ['vip', 'old', 'new']);
expect(result.longDisplayBadges.map((badge) => badge.id), ['vip']);
expect(result.displayBadges.map((badge) => badge.id), ['old', 'new']);
expect(result.honorWallBadges.map((badge) => badge.id), ['new', 'old']);
});
@ -73,10 +71,44 @@ void main() {
],
});
expect(result.longDisplayBadges.map((badge) => badge.id), [
'long',
'vip',
]);
expect(result.displayBadges.map((badge) => badge.id), [
'activity',
'achievement',
]);
expect(result.honorWallBadges.map((badge) => badge.id), [
'achievement',
'activity',
]);
});
test('does not expose long badges as short display badges', () {
final result = SCUserBadgeRes.fromJson({
'longBadges': [
{
'id': 'long-one',
'type': 'LONG',
'selectUrl': 'https://example.com/long-one.png',
},
{
'id': 'vip-one',
'sourceType': 'VIP',
'selectUrl': 'https://example.com/vip-one.png',
},
],
'shortBadges': [],
});
expect(result.longDisplayBadges.map((badge) => badge.id), [
'long-one',
'vip-one',
]);
expect(result.shortDisplayBadges, isEmpty);
expect(result.displayBadges, isEmpty);
expect(result.honorWallBadges, isEmpty);
});
});
}