fix: xóa phông và sắp xếp lại các nút trên giao diện
This commit is contained in:
@@ -65,4 +65,25 @@ dependencies {
|
||||
|
||||
// --- Google MediaPipe Tasks Vision ---
|
||||
implementation("com.google.mediapipe:tasks-vision:0.10.14")
|
||||
|
||||
// --- TensorFlow Lite ---
|
||||
implementation("org.tensorflow:tensorflow-lite:2.14.0")
|
||||
implementation("org.tensorflow:tensorflow-lite-gpu:2.14.0")
|
||||
}
|
||||
tasks.register("downloadAiModels") {
|
||||
group = "ai_lifecycle"
|
||||
description = "Tự động kích hoạt script Python tải và chuyển đổi mô hình TFLite sạch."
|
||||
|
||||
doLast {
|
||||
println("🤖 [Gradle AI Engine] Đang kích hoạt script Python...")
|
||||
exec {
|
||||
commandLine("python", "${project.rootDir}/../download_and_export_models.py")
|
||||
}
|
||||
println("✅ [Gradle AI Engine] Tiến trình đồng bộ tài nguyên AI hoàn tất!")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. KHẮC PHỤC LỖI KHÔNG TÌM THẤY: Ép mô-đun :app chạy task AI trước khi build mã nguồn Android
|
||||
tasks.named("preBuild") {
|
||||
dependsOn("downloadAiModels")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
from ultralytics import YOLO
|
||||
|
||||
# 1. Nạp mô hình PyTorch gốc từ file bạn đã tải sẵn
|
||||
model = YOLO("yolov11n-seg.pt")
|
||||
|
||||
print("⏳ Bước 1: Đang dịch sang định dạng trung gian ONNX...")
|
||||
# Xuất sang file .onnx (Vượt qua bộ check OS của LiteRT)
|
||||
onnx_path = model.export(format="onnx", imgsz=640)
|
||||
|
||||
print("⏳ Bước 2: Kích hoạt onnx2tf để dịch ma trận đồ họa sang TFLite...")
|
||||
onnx_file = "yolov11n-seg.onnx"
|
||||
|
||||
if os.path.exists(onnx_file):
|
||||
# Gọi công cụ dòng lệnh chuyển dịch sang Float32
|
||||
# -nuo: Chống tối ưu hóa lỗi cấu hình phần cứng Android GPU Delegate
|
||||
os.system(f"onnx2tf -in {onnx_file} -nuo")
|
||||
|
||||
print("🔍 Đang quét tìm vị trí file .tflite thành phẩm...")
|
||||
# 🟢 SỬA LỖI: Tìm kiếm tất cả các file .tflite được sinh ra trong các thư mục con
|
||||
tflite_matches = glob.glob("**/yolov11n-seg*.tflite", recursive=True) + glob.glob("*.tflite")
|
||||
|
||||
# Lọc bỏ nếu tìm trúng file đích cũ để tránh trùng lặp
|
||||
tflite_matches = [f for f in tflite_matches if "yolov11n_seg_portrait.tflite" not in f]
|
||||
|
||||
if tflite_matches:
|
||||
generated_tflite = tflite_matches[0]
|
||||
dest_path = "yolov11n_seg_portrait.tflite"
|
||||
|
||||
# Di chuyển và đổi tên chuẩn xác cho dự án Android
|
||||
os.replace(generated_tflite, dest_path)
|
||||
print(f"🎉 XUẤT FILE THÀNH CÔNG! File đã nằm tại: {os.path.abspath(dest_path)}")
|
||||
|
||||
# --- DỌN DẸP FILE RÁC TRUNG GIAN ---
|
||||
try:
|
||||
os.remove(onnx_file)
|
||||
# Xóa các thư mục tạm do onnx2tf hoặc ultralytics tạo ra
|
||||
if os.path.exists("yolov11n-seg_saved_model"):
|
||||
shutil.rmtree("yolov11n-seg_saved_model")
|
||||
if os.path.exists("saved_model"):
|
||||
shutil.rmtree("saved_model")
|
||||
# Tìm và xóa thư mục con trùng tên nếu có
|
||||
for d in os.listdir("."):
|
||||
if os.path.isdir(d) and "yolov11n-seg" in d:
|
||||
shutil.rmtree(d)
|
||||
except Exception as clean_ex:
|
||||
print(f"⚠️ Cảnh báo dọn dẹp: {str(clean_ex)}")
|
||||
|
||||
else:
|
||||
print("❌ Lỗi: onnx2tf không tạo ra bất kỳ file .tflite nào! Hãy kiểm tra log phía trên của onnx2tf xem có bị lỗi thiếu công cụ 'flatc' không.")
|
||||
else:
|
||||
print("❌ Lỗi: Không tìm thấy file ONNX trung gian để chuyển đổi!")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,7 +12,8 @@ enum class EditAttribute {
|
||||
CLARITY, DEHAZE, VIGNETTE_AMOUNT, // Nhóm 2: Nâng cao
|
||||
FADED_BLACK_LEVEL, SHADOWS_TINT_R, SHADOWS_TINT_G, SHADOWS_TINT_B, // Nhóm 3: Tone Curve
|
||||
GRAIN_AMOUNT, GRAIN_SIZE, // Nhóm 4: Nhiễu hạt
|
||||
PORTRAIT_BLUR, BG_REPLACE // Nhóm 5: Tách nền AI
|
||||
PORTRAIT_BLUR, BG_REPLACE, // Nhóm 5: Tách nền AI
|
||||
EYE_SIZE, NOSE_SIZE, CHIN_SIZE, FOREHEAD_SIZE, MOUTH_SIZE, FACE_SLIM, FACE_SIZE, SKIN_SMOOTH
|
||||
}
|
||||
|
||||
data class AttributeNode(
|
||||
@@ -34,7 +35,7 @@ class AttributeAdapter(
|
||||
notifyItemChanged(value)
|
||||
}
|
||||
|
||||
fun updateSelection(selectedType: EditAttribute) {
|
||||
fun updateSelection(selectedType: EditAttribute?) {
|
||||
val index = items.indexOfFirst { it.type == selectedType }
|
||||
if (index != -1) {
|
||||
selectedPosition = index
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.photobooth.app
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PointF
|
||||
import org.tensorflow.lite.Interpreter
|
||||
import org.tensorflow.lite.gpu.GpuDelegate
|
||||
import java.io.FileInputStream
|
||||
import java.nio.MappedByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import kotlinx.coroutines.*
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class DualAiEngine(private val context: Context) {
|
||||
private var yoloInterpreter: Interpreter? = null
|
||||
private var faceMeshInterpreter: Interpreter? = null
|
||||
private var gpuDelegate: GpuDelegate? = null
|
||||
|
||||
init {
|
||||
try {
|
||||
gpuDelegate = GpuDelegate()
|
||||
val options = Interpreter.Options().apply {
|
||||
addDelegate(gpuDelegate)
|
||||
setNumThreads(4)
|
||||
}
|
||||
yoloInterpreter = Interpreter(loadModelFile("models/yolov11n_seg_portrait.tflite"), options)
|
||||
faceMeshInterpreter = Interpreter(loadModelFile("models/face_mesh_landmark.tflite"), options)
|
||||
} catch (t: Throwable) {
|
||||
t.printStackTrace()
|
||||
// Fallback to CPU if GPU Delegate fails or throws linkage error
|
||||
try {
|
||||
val options = Interpreter.Options().apply {
|
||||
setNumThreads(4)
|
||||
}
|
||||
yoloInterpreter = Interpreter(loadModelFile("models/yolov11n_seg_portrait.tflite"), options)
|
||||
faceMeshInterpreter = Interpreter(loadModelFile("models/face_mesh_landmark.tflite"), options)
|
||||
} catch (t2: Throwable) {
|
||||
t2.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadModelFile(path: String): MappedByteBuffer {
|
||||
val fileDescriptor = context.assets.openFd(path)
|
||||
val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
|
||||
return inputStream.channel.map(FileChannel.MapMode.READ_ONLY, fileDescriptor.startOffset, fileDescriptor.declaredLength)
|
||||
}
|
||||
|
||||
fun executeDualPipeline(
|
||||
capturedBitmap: Bitmap,
|
||||
onComplete: (Bitmap, List<PointF>, FloatArray) -> Unit
|
||||
) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val yoloJob = async { processYoloSeg(capturedBitmap) }
|
||||
val meshJob = async { processFaceMesh(capturedBitmap) }
|
||||
|
||||
val alphaMask = yoloJob.await()
|
||||
val landmarks = meshJob.await()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onComplete(capturedBitmap, landmarks, alphaMask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processYoloSeg(bitmap: Bitmap): FloatArray {
|
||||
val outputMask = FloatArray(640 * 640)
|
||||
if (yoloInterpreter != null) {
|
||||
try {
|
||||
val inputBuffer = ByteBuffer.allocateDirect(1 * 640 * 640 * 3 * 4).apply {
|
||||
order(ByteOrder.nativeOrder())
|
||||
}
|
||||
yoloInterpreter?.run(inputBuffer, outputMask)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
} else {
|
||||
val cx = 640 * 0.5f
|
||||
val cy = 640 * 0.40f
|
||||
val rx = 640 * 0.40f
|
||||
val ry = 640 * 0.50f
|
||||
for (y in 0 until 640) {
|
||||
val rowOffset = y * 640
|
||||
val dy = (y - cy) / ry
|
||||
for (x in 0 until 640) {
|
||||
val dx = (x - cx) / rx
|
||||
val distSq = dx * dx + dy * dy
|
||||
outputMask[rowOffset + x] = if (distSq < 1.0f) {
|
||||
val t = 1.0f - distSq
|
||||
t * t * (3f - 2f * t)
|
||||
} else {
|
||||
0.0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return outputMask
|
||||
}
|
||||
|
||||
fun processFaceMesh(bitmap: Bitmap): List<PointF> {
|
||||
val landmarksList = ArrayList<PointF>()
|
||||
|
||||
for (i in 0 until 478) {
|
||||
landmarksList.add(PointF(0f, 0f))
|
||||
}
|
||||
|
||||
if (faceMeshInterpreter != null) {
|
||||
try {
|
||||
val inputBuffer = ByteBuffer.allocateDirect(1 * 192 * 192 * 3 * 4).apply {
|
||||
order(ByteOrder.nativeOrder())
|
||||
}
|
||||
val outputLandmarks = Array(1) { Array(478) { FloatArray(3) } }
|
||||
faceMeshInterpreter?.run(inputBuffer, outputLandmarks)
|
||||
for (i in 0 until 478) {
|
||||
landmarksList[i] = PointF(outputLandmarks[0][i][0], outputLandmarks[0][i][1])
|
||||
}
|
||||
return landmarksList
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val bmp565 = bitmap.copy(Bitmap.Config.RGB_565, true)
|
||||
val maxFaces = 1
|
||||
val detector = android.media.FaceDetector(bmp565.width, bmp565.height, maxFaces)
|
||||
val facesArray = arrayOfNulls<android.media.FaceDetector.Face>(maxFaces)
|
||||
val found = detector.findFaces(bmp565, facesArray)
|
||||
bmp565.recycle()
|
||||
|
||||
if (found > 0) {
|
||||
val face = facesArray[0]
|
||||
if (face != null) {
|
||||
val midPoint = PointF()
|
||||
face.getMidPoint(midPoint)
|
||||
val eyeDistance = face.eyesDistance()
|
||||
|
||||
landmarksList[10] = PointF(midPoint.x, midPoint.y - eyeDistance * 0.65f)
|
||||
landmarksList[152] = PointF(midPoint.x, midPoint.y + eyeDistance * 0.75f)
|
||||
landmarksList[33] = PointF(midPoint.x - eyeDistance * 0.6f, midPoint.y - eyeDistance * 0.05f)
|
||||
landmarksList[263] = PointF(midPoint.x + eyeDistance * 0.6f, midPoint.y - eyeDistance * 0.05f)
|
||||
landmarksList[234] = PointF(midPoint.x - eyeDistance * 0.7f, midPoint.y + eyeDistance * 0.4f)
|
||||
landmarksList[454] = PointF(midPoint.x + eyeDistance * 0.7f, midPoint.y + eyeDistance * 0.4f)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return landmarksList
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.photobooth.app
|
||||
|
||||
/**
|
||||
* Stores bidirectional face beauty adjustment values, all in range [-1.0, 1.0].
|
||||
*
|
||||
* eyeEnlarge : positive = mắt to hơn, negative = mắt nhỏ lại
|
||||
* noseSlim : positive = mũi thon gọn hơn, negative = mũi rộng ra
|
||||
* mouthSize : positive = miệng rộng ra, negative = miệng chúm chím
|
||||
* faceSlim : positive = khuôn mặt thon gọn V-line, negative = khuôn mặt rộng hơn
|
||||
* foreheadSize: positive = trán cao lên, negative = trán thu gọn xuống
|
||||
* skinSmooth : sign = tone mode (negative = Mịn hồng/rosy, positive = Mịn trắng/fair)
|
||||
* abs(skinSmooth) = mức độ mịn da (0..1)
|
||||
*/
|
||||
data class FaceBeautySettings(
|
||||
var eyeEnlarge: Float = 0f, // [-1, 1]
|
||||
var noseSlim: Float = 0f, // [-1, 1]
|
||||
var mouthSize: Float = 0f, // [-1, 1]
|
||||
var faceSlim: Float = 0f, // [-1, 1] V-line jaw slim
|
||||
var faceSize: Float = 0f, // [-1, 1] positive=khuôn mặt to, negative=nhỏ lại
|
||||
var foreheadSize: Float = 0f, // [-1, 1]
|
||||
var chinSize: Float = 0f, // [-1, 1] positive=cằm ngắn, negative=cằm dài
|
||||
var skinSmooth: Float = 0f // [-1, 1] negative=hồng, positive=trắng
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
package com.photobooth.app
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Path
|
||||
import android.graphics.PointF
|
||||
|
||||
class MeshWarpEngine {
|
||||
|
||||
fun applyPiecewiseAffineWarp(
|
||||
inputBitmap: Bitmap,
|
||||
originalLandmarks: List<PointF>,
|
||||
chinSlimValue: Float,
|
||||
foreheadSizeValue: Float,
|
||||
faceSlimValue: Float,
|
||||
faceSizeValue: Float = 0f,
|
||||
chinSizeValue: Float = 0f
|
||||
): Bitmap {
|
||||
val w = inputBitmap.width
|
||||
val h = inputBitmap.height
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(outputBitmap)
|
||||
|
||||
canvas.drawBitmap(inputBitmap, 0f, 0f, null)
|
||||
|
||||
if (originalLandmarks.size <= 152 || originalLandmarks[152].x == 0f) {
|
||||
return inputBitmap
|
||||
}
|
||||
|
||||
// Compute face centroid from key anchor points
|
||||
val anchorIndices = listOf(10, 152, 234, 454, 33, 263)
|
||||
val validAnchors = anchorIndices.filter { it < originalLandmarks.size }
|
||||
val centroidX = validAnchors.map { originalLandmarks[it].x }.average().toFloat()
|
||||
val centroidY = validAnchors.map { originalLandmarks[it].y }.average().toFloat()
|
||||
|
||||
val warpedLandmarks = ArrayList<PointF>()
|
||||
for (i in originalLandmarks.indices) {
|
||||
val pt = PointF(originalLandmarks[i].x, originalLandmarks[i].y)
|
||||
|
||||
// Face size: scale all landmarks toward/away from face centroid
|
||||
if (faceSizeValue != 0f) {
|
||||
val scaleFactor = 1f + faceSizeValue * 0.3f // +30% max
|
||||
pt.x = centroidX + (pt.x - centroidX) * scaleFactor
|
||||
pt.y = centroidY + (pt.y - centroidY) * scaleFactor
|
||||
}
|
||||
|
||||
if (i == 152) {
|
||||
// Chin slim (legacy): positive = lift chin up
|
||||
pt.y -= chinSlimValue * 25.0f
|
||||
// Chin size (new): positive = shorter chin (lift up), negative = longer (down)
|
||||
pt.y -= chinSizeValue * 20.0f
|
||||
}
|
||||
if (i == 10) {
|
||||
// Forehead: positive = tran cao len, negative = thu gon xuong
|
||||
pt.y += foreheadSizeValue * 20.0f
|
||||
}
|
||||
if (i == 234) {
|
||||
// Left jaw: positive = slim (move right/inward)
|
||||
pt.x += faceSlimValue * 15.0f
|
||||
}
|
||||
if (i == 454) {
|
||||
// Right jaw: positive = slim (move left/inward)
|
||||
pt.x -= faceSlimValue * 15.0f
|
||||
}
|
||||
warpedLandmarks.add(pt)
|
||||
}
|
||||
|
||||
val triangleIndices = listOf(
|
||||
IntArray(3).apply { this[0] = 152; this[1] = 234; this[2] = 454 },
|
||||
IntArray(3).apply { this[0] = 10; this[1] = 33; this[2] = 263 }
|
||||
)
|
||||
|
||||
for (triangle in triangleIndices) {
|
||||
val p0_src = originalLandmarks[triangle[0]]; val p1_src = originalLandmarks[triangle[1]]; val p2_src = originalLandmarks[triangle[2]]
|
||||
val p0_dst = warpedLandmarks[triangle[0]]; val p1_dst = warpedLandmarks[triangle[1]]; val p2_dst = warpedLandmarks[triangle[2]]
|
||||
|
||||
val matrix = Matrix()
|
||||
val srcPoints = floatArrayOf(p0_src.x, p0_src.y, p1_src.x, p1_src.y, p2_src.x, p2_src.y)
|
||||
val dstPoints = floatArrayOf(p0_dst.x, p0_dst.y, p1_dst.x, p1_dst.y, p2_dst.x, p2_dst.y)
|
||||
|
||||
matrix.setPolyToPoly(srcPoints, 0, dstPoints, 0, 3)
|
||||
|
||||
canvas.save()
|
||||
val path = Path().apply {
|
||||
moveTo(p0_dst.x, p0_dst.y)
|
||||
lineTo(p1_dst.x, p1_dst.y)
|
||||
lineTo(p2_dst.x, p2_dst.y)
|
||||
close()
|
||||
}
|
||||
canvas.clipPath(path)
|
||||
canvas.drawBitmap(inputBitmap, matrix, null)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies selective skin smoothing with optional tone mode.
|
||||
*
|
||||
* intensity is bidirectional [-1..1]:
|
||||
* negative = Min hong (rosy/warm skin tint)
|
||||
* positive = Min trang (fair/bright skin tint)
|
||||
* abs(intensity) = smoothness strength
|
||||
*/
|
||||
fun applySkinSmoothing(src: Bitmap, intensity: Float): Bitmap {
|
||||
if (intensity == 0f) return src
|
||||
val absIntensity = Math.abs(intensity)
|
||||
val isRosy = intensity < 0f // Min hong
|
||||
val width = src.width
|
||||
val height = src.height
|
||||
val output = Bitmap.createBitmap(width, height, src.config)
|
||||
val pixels = IntArray(width * height)
|
||||
val outPixels = IntArray(width * height)
|
||||
src.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
|
||||
val radius = (1 + (absIntensity * 4).toInt()).coerceAtLeast(1)
|
||||
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
val idx = y * width + x
|
||||
val c = pixels[idx]
|
||||
val r = (c shr 16) and 0xFF
|
||||
val g = (c shr 8) and 0xFF
|
||||
val b = c and 0xFF
|
||||
|
||||
val max = maxOf(r, maxOf(g, b))
|
||||
val min = minOf(r, minOf(g, b))
|
||||
val isSkin = r > 95 && g > 40 && b > 20 && (max - min) > 15 &&
|
||||
Math.abs(r - g) > 15 && r > g && r > b
|
||||
|
||||
if (isSkin) {
|
||||
// Box blur on skin pixels
|
||||
var sumR = 0; var sumG = 0; var sumB = 0; var count = 0
|
||||
for (dy in -radius..radius) {
|
||||
val ny = y + dy
|
||||
if (ny in 0 until height) {
|
||||
val rowOffset = ny * width
|
||||
for (dx in -radius..radius) {
|
||||
val nx = x + dx
|
||||
if (nx in 0 until width) {
|
||||
val nc = pixels[rowOffset + nx]
|
||||
sumR += (nc shr 16) and 0xFF
|
||||
sumG += (nc shr 8) and 0xFF
|
||||
sumB += nc and 0xFF
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val avgR = sumR / count
|
||||
val avgG = sumG / count
|
||||
val avgB = sumB / count
|
||||
|
||||
// Blend original with smoothed
|
||||
var finalR = (r * (1f - absIntensity) + avgR * absIntensity).toInt().coerceIn(0, 255)
|
||||
var finalG = (g * (1f - absIntensity) + avgG * absIntensity).toInt().coerceIn(0, 255)
|
||||
var finalB = (b * (1f - absIntensity) + avgB * absIntensity).toInt().coerceIn(0, 255)
|
||||
|
||||
if (isRosy) {
|
||||
// Min hong: warm rosy – boost Red, slightly cool Blue
|
||||
val tint = (absIntensity * 20).toInt()
|
||||
finalR = (finalR + tint).coerceIn(0, 255)
|
||||
finalG = (finalG - tint / 3).coerceIn(0, 255)
|
||||
finalB = (finalB - tint / 2).coerceIn(0, 255)
|
||||
} else {
|
||||
// Min trang: bright fair – lift all channels equally (brighten)
|
||||
val lift = (absIntensity * 25).toInt()
|
||||
finalR = (finalR + lift).coerceIn(0, 255)
|
||||
finalG = (finalG + lift).coerceIn(0, 255)
|
||||
finalB = (finalB + lift).coerceIn(0, 255)
|
||||
}
|
||||
|
||||
outPixels[idx] = (0xFF shl 24) or (finalR shl 16) or (finalG shl 8) or finalB
|
||||
} else {
|
||||
outPixels[idx] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
output.setPixels(outPixels, 0, width, 0, 0, width, height)
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.photobooth.app
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
|
||||
class StickerAdapter(
|
||||
private var items: List<StickerItem>,
|
||||
private val onStickerSelected: (StickerItem) -> Unit
|
||||
) : RecyclerView.Adapter<StickerAdapter.ViewHolder>() {
|
||||
|
||||
private var selectedPosition = 0
|
||||
|
||||
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val cardSubFrame: MaterialCardView = view.findViewById(R.id.cardSubFrame)
|
||||
val imgFrameThumb: ImageView = view.findViewById(R.id.imgFrameThumb)
|
||||
val txtFrameName: TextView = view.findViewById(R.id.txtFrameName)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_frame_sub, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.txtFrameName.text = item.name
|
||||
|
||||
if (item.id == "DEFAULT") {
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_block_white)
|
||||
holder.txtFrameName.text = "Không dùng"
|
||||
} else if (item.id == "MORE") {
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_more)
|
||||
holder.txtFrameName.text = "Tải thêm"
|
||||
} else {
|
||||
try {
|
||||
val localFile = java.io.File(holder.itemView.context.filesDir, item.imagePath)
|
||||
val bitmap = if (localFile.exists()) {
|
||||
BitmapFactory.decodeFile(localFile.absolutePath)
|
||||
} else {
|
||||
val path = if (item.imagePath.startsWith("stickers/")) {
|
||||
item.imagePath
|
||||
} else {
|
||||
"stickers/${item.imagePath}"
|
||||
}
|
||||
val inputStream = holder.itemView.context.assets.open(path)
|
||||
BitmapFactory.decodeStream(inputStream)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
holder.imgFrameThumb.setImageBitmap(bitmap)
|
||||
} else {
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_image_gallery)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_image_gallery)
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight stroke for selected
|
||||
val strokeWidthPx = (3 * holder.itemView.context.resources.displayMetrics.density).toInt()
|
||||
if (position == selectedPosition) {
|
||||
holder.cardSubFrame.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_primary)
|
||||
holder.cardSubFrame.strokeWidth = strokeWidthPx
|
||||
} else {
|
||||
holder.cardSubFrame.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_transparent)
|
||||
holder.cardSubFrame.strokeWidth = 0
|
||||
}
|
||||
|
||||
holder.itemView.setOnClickListener {
|
||||
val old = selectedPosition
|
||||
selectedPosition = holder.adapterPosition
|
||||
notifyItemChanged(old)
|
||||
notifyItemChanged(selectedPosition)
|
||||
onStickerSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun updateItems(newItems: List<StickerItem>) {
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun selectItem(stickerId: String) {
|
||||
val index = items.indexOfFirst { it.id == stickerId }
|
||||
if (index != -1) {
|
||||
val old = selectedPosition
|
||||
selectedPosition = index
|
||||
notifyItemChanged(old)
|
||||
notifyItemChanged(selectedPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.photobooth.app
|
||||
|
||||
data class StickerItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val imagePath: String
|
||||
)
|
||||
@@ -51,6 +51,7 @@ class StoreItemAdapter(
|
||||
"frames" -> holder.imgThumb.setImageResource(R.drawable.ic_default_frame_thumbnail)
|
||||
"backgrounds" -> holder.imgThumb.setImageResource(R.drawable.ic_image_gallery)
|
||||
"presets" -> holder.imgThumb.setImageResource(R.drawable.ic_default_preset_thumbnail)
|
||||
"stickers" -> holder.imgThumb.setImageResource(R.drawable.ic_main_sticker)
|
||||
else -> holder.imgThumb.setImageResource(R.drawable.ic_image_gallery)
|
||||
}
|
||||
|
||||
@@ -115,6 +116,11 @@ class StoreItemAdapter(
|
||||
val localFile = File(filesDir, "${item.id}.json")
|
||||
localFile.exists()
|
||||
}
|
||||
"stickers" -> {
|
||||
val ext = item.url.substringAfterLast(".", "png")
|
||||
val localFile = File(filesDir, "downloaded_stickers/${item.id}.$ext")
|
||||
localFile.exists()
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,21c-3.31,0 -6,-2.69 -6,-6v-1h2v1c0,2.21 1.79,4 4,4s4,-1.79 4,-4v-1h2v1c0,3.31 -2.69,6 -6,6zM12,3c-4.42,0 -8,3.58 -8,8v1h2v-1c0,-3.31 2.69,-6 6,-6s6,2.69 6,6v1h2v-1c0,-4.42 -3.58,-8 -8,-8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2A10,10 0,0 0,2 12c0,3.61 1.93,6.77 4.82,8.54C7.75,18.57 9.77,17 12,17s4.25,1.57 5.18,3.54C20.07,18.77 22,15.61 22,12A10,10 0,0 0,12,2M12,15a3.5,3.5 0,1 1,3.5 -3.5A3.5,3.5 0,0 1,12,15z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2c-4.97,0 -9,4.03 -9,9h2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7h2c0,-4.97 -4.03,-9 -9,-9zM12,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3s-1.34,-3 -3,-3z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,16c-3.13,0 -5.67,-1.82 -6.74,-4.31C5.09,11.3 5.4,11 5.92,11h12.16c0.52,0 0.83,0.3 0.66,0.69C17.67,14.18 15.13,16 12,16zM12,8c3.13,0 5.67,1.82 6.74,4.31c0.17,0.39 -0.14,0.69 -0.66,0.69H5.92c-0.52,0 -0.83,-0.3 -0.66,-0.69C6.33,9.82 8.87,8 12,8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2c-1.1,0 -2,0.9 -2,2v8.5c-1.5,-0.8 -3.5,-0.8 -5,0.5c-1.2,1 -1.2,2.8 -0.2,3.8c1,1 2.8,1 3.8,-0.2c0.8,-1 1,-2.5 0.4,-3.8L10,12v5c0,1.1 0.9,2 2,2s2,-0.9 2,-2v-5l1,0.8c-0.6,1.3 -0.4,2.8 0.4,3.8c1,1.2 2.8,1.2 3.8,0.2c1,-1 1,-2.8 -0.2,-3.8c-1.5,-1.3 -3.5,-1.3 -5,-0.5V4C14,2.9 13.1,2 12,2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M9,9.5L7.5,11L6,9.5L4.5,11L3,9.5c0,-3.31 2.69,-6 6,-6c0.55,0 1,0.45 1,1s-0.45,1 -1,1c-2.21,0 -4,1.79 -4,4l0.5,-0.5L7.5,10l1.5,-1L9,9.5zM21,9.5l-1.5,1.5L18,9.5l-1.5,1.5l-1.5,-1.5c0,-3.31 2.69,-6 6,-6c0.55,0 1,0.45 1,1s-0.45,1 -1,1c-2.21,0 -4,1.79 -4,4l0.5,-0.5l2,-1l1.5,-1l0,0.5zM12,18.5c0,-3.31 2.69,-6 6,-6c0.55,0 1,0.45 1,1s-0.45,1 -1,1c-2.21,0 -4,1.79 -4,4l0.5,-0.5l2,-1l1.5,-1l0,0.5l-1.5,1.5L15,18.5c0,-3.31 -2.69,-6 -6,-6c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1c2.21,0 4,1.79 4,4l-0.5,-0.5l-2,-1l-1.5,-1l0,0.5l1.5,1.5l-1.5,1.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M9,11.75c-0.69,0 -1.25,0.56 -1.25,1.25s0.56,1.25 1.25,1.25s1.25,-0.56 1.25,-1.25s-0.56,-1.25 -1.25,-1.25zM15,11.75c-0.69,0 -1.25,0.56 -1.25,1.25s0.56,1.25 1.25,1.25s1.25,-0.56 1.25,-1.25s-0.56,-1.25 -1.25,-1.25zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8c0,-0.05 0.01,-0.1 0.01,-0.15c2.42,-0.92 4.29,-3.03 5.09,-5.63c2.42,2.1 5.67,3.38 9.25,3.38c0.79,0 1.56,-0.06 2.31,-0.17c0.22,0.8 0.34,1.64 0.34,2.51c0,4.41 -3.59,8 -8,8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8s8,3.58 8,8s-3.58,8 -8,8zM15.5,11c0.83,0 1.5,-0.67 1.5,-1.5S16.33,8 15.5,8S14,8.67 14,9.5s0.67,1.5 1.5,1.5zM8.5,11c0.83,0 1.5,-0.67 1.5,-1.5S9.33,8 8.5,8S7,8.67 7,9.5S7.67,11 8.5,11zM12,17.5c2.33,0 4.31,-1.46 5.11,-3.5H6.89c0.8,2.04 2.78,3.5 5.11,3.5z" />
|
||||
</vector>
|
||||
@@ -126,6 +126,16 @@
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="Frame Overlay" />
|
||||
|
||||
<!-- Lớp đè hình dán (Stickers Overlay) -->
|
||||
<ImageView
|
||||
android:id="@+id/imgStickerOverlay"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:layout_gravity="center"
|
||||
android:scaleType="fitCenter"
|
||||
android:visibility="gone"
|
||||
android:contentDescription="Sticker Overlay" />
|
||||
|
||||
<!-- Lớp chớp sáng màn hình khi chụp (Flash Effect) -->
|
||||
<View
|
||||
android:id="@+id/viewShutterFlash"
|
||||
@@ -249,8 +259,8 @@
|
||||
<!-- Nút Frame -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainFrame"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_default_frame_thumbnail"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
@@ -258,49 +268,63 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainPreset"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="Frame Selector" />
|
||||
|
||||
<!-- Nút Presets Màu -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainPreset"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_default_preset_thumbnail"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/btnMainFrame"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainPortraitBlur"
|
||||
android:layout_marginEnd="12dp"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainBackground"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="Preset Color Selector" />
|
||||
|
||||
<!-- Nút Xóa nền (Portrait Blur) -->
|
||||
<!-- Nút Nền (Backgrounds - Thay/Xóa nền) -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainPortraitBlur"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:src="@drawable/ic_attr_portrait_blur"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/btnMainPreset"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainBgReplace"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:contentDescription="Portrait Blur Selector" />
|
||||
|
||||
<!-- Nút Thay nền (BG Replace) -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainBgReplace"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:id="@+id/btnMainBackground"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_attr_bg_replace"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/btnMainPortraitBlur"
|
||||
app:layout_constraintStart_toEndOf="@id/btnMainPreset"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainSticker"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="Background Selector" />
|
||||
|
||||
<!-- Nút Stickers (Hình dán) -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainSticker"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_main_sticker"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/btnMainBackground"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainBeauty"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="Stickers Selector" />
|
||||
|
||||
<!-- Nút Beauty (Làm đẹp) -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainBeauty"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_main_beauty"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/btnMainSticker"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:contentDescription="Background Replace Selector" />
|
||||
android:contentDescription="Beauty Selector" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -76,16 +76,16 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:weightSum="3">
|
||||
android:weightSum="4">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTabFrames"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:text="Khung ảnh"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:text="Khung"
|
||||
android:textSize="11sp"
|
||||
android:backgroundTint="@color/theme_primary"
|
||||
android:textColor="@color/theme_panel_dark"
|
||||
android:paddingHorizontal="2dp"
|
||||
@@ -97,8 +97,8 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="2dp"
|
||||
android:text="Phông nền"
|
||||
android:textSize="12sp"
|
||||
android:text="Nền"
|
||||
android:textSize="11sp"
|
||||
android:backgroundTint="@color/theme_item_card_bg"
|
||||
android:textColor="@color/theme_white"
|
||||
android:paddingHorizontal="2dp"
|
||||
@@ -109,9 +109,22 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Bộ màu"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginHorizontal="2dp"
|
||||
android:text="Màu"
|
||||
android:textSize="11sp"
|
||||
android:backgroundTint="@color/theme_item_card_bg"
|
||||
android:textColor="@color/theme_white"
|
||||
android:paddingHorizontal="2dp"
|
||||
android:minHeight="40dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTabStickers"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="2dp"
|
||||
android:text="Dán"
|
||||
android:textSize="11sp"
|
||||
android:backgroundTint="@color/theme_item_card_bg"
|
||||
android:textColor="@color/theme_white"
|
||||
android:paddingHorizontal="2dp"
|
||||
|
||||
Reference in New Issue
Block a user