feat: thêm tính năng tải frame, presets, background từ cloud

This commit is contained in:
2026-07-07 07:57:02 +07:00
parent 9af4498897
commit cdb9e450b5
12 changed files with 1004 additions and 103 deletions
+106
View File
@@ -0,0 +1,106 @@
Để thực hiện các tính năng chỉnh sửa chi tiết cấu trúc khuôn mặt như phóng to/thu nhỏ mắt, thu gọn cánh mũi, nâng cằm, hạ trán ngay trên điện thoại, các ứng dụng nhiếp ảnh hiện nay sử dụng một nhóm công nghệ AI chuyên biệt gọi là **Mô hình Nhận diện Điểm cốt lõi Khuôn mặt (Face Landmark Detection)** kết hợp với thuật toán **Biến dạng Đồ họa Động (Image Warping/Mesh Deformation)**.
Dưới đây là kế hoạch Markdown chi tiết mô tả cơ chế hoạt động của các tính năng làm đẹp này, các mô hình AI on-device (chạy trực tiếp trên chip điện thoại) phổ biến, và cách tích hợp chúng vào dự án của bạn.
---
# 👤 TÀI LIỆU CÔNG NGHỆ: CHỈNH SỬA CHI TIẾT CHÂN DUNG (FACE RECOGNITION & LANDMARK WARPING) ON-DEVICE
Mục tiêu cốt lõi:
1. Hiểu cách AI định vị các bộ phận: Mắt, Mũi, Miệng, Trán, Cằm.
2. Ứng dụng ma trận điểm (Mesh) để co giãn, thay đổi kích thước bộ phận mà không làm méo tổng thể bức ảnh.
3. Giới thiệu các mô hình AI siêu nhẹ, chạy mượt mà 60 FPS trực tiếp trên phần cứng điện thoại.
---
## 📐 1. Nguyên Lý Hoạt Động Của AI Làm Đẹp (The Anatomy of Digital Beauty)
Quy trình chỉnh sửa chân dung bằng AI trên điện thoại diễn ra qua 3 bước nghiêm ngặt sau:
### 🔹 Bước 1: Quét hệ thống điểm cốt lõi (Face Landmark Detection)
Mô hình AI sẽ không nhìn khuôn mặt như một tấm ảnh phẳng, mà nó sẽ tự động chấm lên mặt bạn từ **68 đến 468 điểm tọa độ cố định** (Landmarks).
* **Mắt:** Được bao bọc bởi khoảng 6-8 điểm quanh mí mắt.
* **Mũi:** Cố định bởi các điểm dọc sống mũi và cánh mũi.
* **Trán và Cằm:** Định vị bằng các điểm dọc theo đường viền xương hàm (Jawline) và chân tóc.
### 🔹 Bước 2: Tạo lưới đa giác lập thể (Face Mesh Generation)
Từ các điểm tọa độ này, AI nối chúng lại với nhau tạo thành một mạng lưới hàng trăm đa giác nhỏ bao bọc lấy khuôn mặt (giống như mặt nạ 3D lưới).
### 🔹 Bước 3: Biến dạng hình học cục bộ (Local Mesh Deformation / Warping)
Khi bạn kéo thanh trượt Ruler trên ứng dụng (ví dụ: Thu nhỏ mũi hoặc phóng to mắt):
* Thuật toán sẽ **chỉ dịch chuyển tọa độ các điểm thuộc bộ phận đó** (Ví dụ: ép các điểm cánh mũi dịch vào tâm, hoặc đẩy các điểm mí mắt giãn ra xa nhau).
* Phần mềm đồ họa (thông qua GPU Shader như OpenGL/Vulkan/Metal) sẽ tự động co giãn các pixel ảnh nằm bên trong các đa giác tương ứng. Vì chỉ có vùng lưới của bộ phận đó dịch chuyển, nên phông nền xung quanh hoặc các bộ phận khác hoàn toàn không bị méo mó theo.
---
## 📦 2. Các Mô Hình AI Chuyên Chức Năng Này Trên Điện Thoại
Nếu bạn muốn tích hợp tính năng này vào ứng dụng của mình, đây là những mô hình AI mã nguồn mở, siêu nhẹ (On-device) tốt nhất hiện nay:
### 🌟 1. Google MediaPipe Face Mesh (Khuyên dùng nhất)
* **Dung lượng:** ~3MB - 5MB (Dạng `.tflite`).
* **Khả năng:** Nhận diện thời gian thực lên tới **468 điểm tọa độ 3D** trên khuôn mặt (có bản nâng cấp lên 478 điểm bao gồm cả con ngươi mắt).
* **Hiệu năng:** Chạy cực mượt (gần 60 FPS) trên cả Android và iOS nhờ tối ưu hóa GPU phần cứng. Nó bóc tách cực chi tiết viền môi, mí mắt, giúp việc phóng to mắt hay thu nhỏ môi chính xác đến từng pixel.
### 🌟 2. Ý tưởng từ InsightFace (Mô hình chuyên sâu)
* **Khả năng:** Nhận diện trọn vẹn cấu trúc xương mặt để phục vụ căn chỉnh tỷ lệ vàng (Face Reshaping).
* **Nhược điểm:** Nặng hơn MediaPipe, thường dùng cho việc xử lý ảnh tĩnh sau khi chụp hơn là chạy Live preview trên kính ngắm.
---
## 💻 3. Logic Thiết Kế Mã Nguồn Giả Lập Điều Chỉnh Kích Thước (`MainActivity.kt`)
Khi người dùng tăng giảm thông số trên thanh kéo, chúng ta can thiệp trực tiếp vào ma trận điểm Mesh của MediaPipe trước khi render ra màn hình:
```kotlin
import android.graphics.Bitmap
import com.google.mediapipe.tasks.vision.facemesh.FaceMeshResult
fun applyFaceReshaping(
inputBitmap: Bitmap,
faceMeshResult: FaceMeshResult,
eyeSizeFactor: Float, // Hệ số chỉnh mắt (Ví dụ: 1.0 là gốc, 1.2 là phóng to 20%)
noseSizeFactor: Float // Hệ số chỉnh mũi (Ví dụ: 0.9 là thu nhỏ cánh mũi 10%)
): Bitmap {
val outputBitmap = Bitmap.createBitmap(inputBitmap.width, inputBitmap.height, Bitmap.Config.ARGB_8888)
// 1. Trích xuất danh sách tọa độ 468 điểm từ MediaPipe
val landmarks = faceMeshResult.multiFaceLandmarks()[0]
// 2. Định vị danh sách ID điểm thuộc về Mắt trái, Mắt phải, Cánh mũi
val leftEyeCenter = calculateCenterPoint(landmarks, listOf(33, 133, 159, 145)) // Điểm trung tâm mắt
val noseCenter = calculateCenterPoint(landmarks, listOf(1, 2, 98, 327)) // Điểm trung tâm mũi
// 3. Chạy thuật toán Warp Pixel cục bộ (Local Texture Warping)
// Duyệt qua các pixel, nếu pixel nằm gần vùng mắt/mũi, ta nhân tọa độ với hệ số Factor
// để ép hình ảnh co lại hoặc giãn ra từ tâm bộ phận đó.
// Thuật toán này được thực thi bằng mã Shader (OpenGL ES) dưới GPU để đạt tốc độ thời gian thực.
return outputBitmap
}
```
---
## 🔄 4. Định Hướng Tích Hợp Vào Dự Án Photobooth Hiện Tại
Tính năng này có cấu trúc vận hành hoàn toàn đồng bộ với hệ thống **MediaPipe Multiclass Segmenter****Focus vật lý** mà chúng ta đã xây dựng:
1. **Hiển thị trên Viewfinder (Real-time Preview):** Luồng camera Preview thô vẫn chạy 60 FPS. Ta nạp model `Face Mesh` chạy song song (vì model này rất nhẹ, chỉ tốn khoảng 2-3ms để quét 1 khung hình). GPU của điện thoại sẽ áp lưới ma trận màu và lưới co giãn bộ phận trực tiếp lên Viewfinder. Bạn sẽ thấy mắt mình to lên hoặc mũi nhỏ lại **ngay khi đang ngắm chuẩn bị chụp**.
2. **Khi bấm Shutter:** Thấu kính camera thực hiện lấy nét vật lý chuẩn xác $\rightarrow$ Chụp ảnh gốc độ phân giải cao $\rightarrow$ Gửi ảnh gốc + Các thông số co giãn mặt (`eyeSize, noseSize`) xuống luồng ngầm Coroutine để xử lý xuất file JPG hoàn chỉnh sắc nét.
---
## 📅 5. Checklist Thử Nghiệm Tính Năng (QA Checklist)
* [ ] **Kiểm thử vùng biên (No Distort):** Kéo thanh trượt "Thu nhỏ cằm" hoặc "Phóng to mắt" lên mức tối đa. Quan sát kỹ phần phông nền xung quanh (bức tường, cửa sổ sắt phía sau). (Yêu cầu: Chỉ có bộ phận trên mặt thay đổi kích thước, phông nền thẳng phía sau tuyệt đối không được bị cong hay méo theo tay kéo).
* [ ] **Kiểm thử góc nghiêng (Angle Rotation):** Thử quay nghiêng mặt góc 30-45 độ rồi chỉnh thông số. (Yêu cầu: AI Face Mesh vẫn phải khóa được vị trí mắt mũi theo chiều không gian 3D để co giãn đúng hướng thấu kính, không bị lệch vùng chọn ra ngoài má).
@@ -17,7 +17,8 @@
android:icon="@android:mipmap/sym_def_app_icon" android:icon="@android:mipmap/sym_def_app_icon"
android:label="Retro Photobooth" android:label="Retro Photobooth"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"> android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -41,10 +41,18 @@ class BackgroundOptionAdapter(
if (item.assetPath == null) { if (item.assetPath == null) {
holder.imgBgThumb.setImageResource(R.drawable.ic_block_white) holder.imgBgThumb.setImageResource(R.drawable.ic_block_white)
} else if (item.id == "MORE") {
holder.imgBgThumb.setImageResource(R.drawable.ic_more)
holder.txtBgName.text = "Tải thêm"
} else { } else {
try { try {
val localFile = java.io.File(holder.itemView.context.filesDir, item.assetPath)
val bitmap = if (localFile.exists()) {
BitmapFactory.decodeFile(localFile.absolutePath)
} else {
val inputStream = holder.itemView.context.assets.open(item.assetPath) val inputStream = holder.itemView.context.assets.open(item.assetPath)
val bitmap = BitmapFactory.decodeStream(inputStream) BitmapFactory.decodeStream(inputStream)
}
holder.imgBgThumb.setImageBitmap(bitmap) holder.imgBgThumb.setImageBitmap(bitmap)
} catch (e: Exception) { } catch (e: Exception) {
holder.imgBgThumb.setImageDrawable(null) holder.imgBgThumb.setImageDrawable(null)
@@ -71,4 +79,14 @@ class BackgroundOptionAdapter(
} }
override fun getItemCount(): Int = items.size override fun getItemCount(): Int = items.size
fun selectItem(itemId: String) {
val index = items.indexOfFirst { it.id == itemId }
if (index != -1 && index != selectedPosition) {
val oldPos = selectedPosition
selectedPosition = index
notifyItemChanged(oldPos)
notifyItemChanged(selectedPosition)
}
}
} }
@@ -37,15 +37,23 @@ class FrameItemAdapter(
if (item.id == "DEFAULT") { if (item.id == "DEFAULT") {
holder.imgFrameThumb.setImageResource(R.drawable.ic_block_white) holder.imgFrameThumb.setImageResource(R.drawable.ic_block_white)
holder.txtFrameName.text = holder.itemView.context.getString(R.string.default_frame) holder.txtFrameName.text = holder.itemView.context.getString(R.string.default_frame)
} else if (item.id == "MORE") {
holder.imgFrameThumb.setImageResource(R.drawable.ic_more)
holder.txtFrameName.text = "Tải thêm"
} else { } else {
try { try {
val localFile = java.io.File(holder.itemView.context.filesDir, item.imageFileName)
val bitmap = if (localFile.exists()) {
BitmapFactory.decodeFile(localFile.absolutePath)
} else {
val path = if (item.imageFileName.startsWith("frames/")) { val path = if (item.imageFileName.startsWith("frames/")) {
item.imageFileName item.imageFileName
} else { } else {
"frames/${item.imageFileName}" "frames/${item.imageFileName}"
} }
val inputStream = holder.itemView.context.assets.open(path) val inputStream = holder.itemView.context.assets.open(path)
val bitmap = BitmapFactory.decodeStream(inputStream) BitmapFactory.decodeStream(inputStream)
}
holder.imgFrameThumb.setImageBitmap(bitmap) holder.imgFrameThumb.setImageBitmap(bitmap)
} catch (e: Exception) { } catch (e: Exception) {
holder.imgFrameThumb.setImageDrawable(null) holder.imgFrameThumb.setImageDrawable(null)
@@ -78,4 +86,14 @@ class FrameItemAdapter(
selectedPosition = 0 // Reset to default when switching stack selectedPosition = 0 // Reset to default when switching stack
notifyDataSetChanged() notifyDataSetChanged()
} }
fun selectItem(itemId: String) {
val index = items.indexOfFirst { it.id == itemId }
if (index != -1 && index != selectedPosition) {
val oldPos = selectedPosition
selectedPosition = index
notifyItemChanged(oldPos)
notifyItemChanged(selectedPosition)
}
}
} }
@@ -88,11 +88,17 @@ class MainActivity : AppCompatActivity() {
private var currentBackgroundMode = BackgroundMode.DEFAULT private var currentBackgroundMode = BackgroundMode.DEFAULT
private fun updateBackgroundMode() { private fun updateBackgroundMode() {
val oldMode = currentBackgroundMode
currentBackgroundMode = when { currentBackgroundMode = when {
selectedBgAssetPath != null || blurIntensity > 0f -> BackgroundMode.AI_BLUR selectedBgAssetPath != null || blurIntensity > 0f -> BackgroundMode.AI_BLUR
isPortraitBlurModeOpen -> BackgroundMode.HARDWARE_DOF isPortraitBlurModeOpen -> BackgroundMode.HARDWARE_DOF
else -> BackgroundMode.DEFAULT else -> BackgroundMode.DEFAULT
} }
if (currentBackgroundMode == BackgroundMode.AI_BLUR && oldMode != BackgroundMode.AI_BLUR) {
// Tắt focus API: Hủy bỏ lấy nét thấu kính vật lý và reset class focus
userSelectedClassFocus = -1
cameraControl?.cancelFocusAndMetering()
}
} }
private fun getImageSegmenter(): ImageSegmenter? { private fun getImageSegmenter(): ImageSegmenter? {
@@ -162,7 +168,7 @@ class MainActivity : AppCompatActivity() {
allFrames = listOf( allFrames = listOf(
FrameItem("DEFAULT", "Mặc định", ""), FrameItem("DEFAULT", "Mặc định", ""),
FrameItem("instax_mini_single", "Instax Mini", "frames/instaxframe.png") FrameItem("instax_mini_single", "Instax Mini", "frames/instaxframe.png")
) ) + loadDownloadedFrames() + listOf(FrameItem("MORE", "Tải thêm", ""))
// Initialize default Color Presets // Initialize default Color Presets
val defaultPresets = listOf( val defaultPresets = listOf(
@@ -209,7 +215,18 @@ class MainActivity : AppCompatActivity() {
) )
// Load custom user saved presets from filesDir // Load custom user saved presets from filesDir
colorPresets = defaultPresets + loadCustomPresets() colorPresets = defaultPresets + loadCustomPresets() + listOf(
ColorPreset(
id = "MORE",
name = "Tải thêm",
themeCategory = "None",
isEditable = false,
basic = BasicAdjustments(0f, 0f, 0f, 0f, 0f, 0f),
advanced = AdvancedEffects(0f, 0f, 0f),
toneCurve = ToneCurveEmulation(0f, 0f, 0f, 0f),
grain = FilmGrain(0f, 0f)
)
)
} }
private fun loadCustomPresets(): List<ColorPreset> { private fun loadCustomPresets(): List<ColorPreset> {
@@ -276,13 +293,21 @@ class MainActivity : AppCompatActivity() {
binding.rvHorizontalTimeline.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) binding.rvHorizontalTimeline.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
frameItemAdapter = FrameItemAdapter(allFrames) { frame -> frameItemAdapter = FrameItemAdapter(allFrames) { frame ->
if (frame.id == "MORE") {
openOnlineStoreDialog("frames")
} else {
setFrameOverlay(frame.imageFileName) setFrameOverlay(frame.imageFileName)
updateFrameModeIcon(frame) updateFrameModeIcon(frame)
} }
}
presetAdapter = PresetAdapter(colorPresets, { preset -> presetAdapter = PresetAdapter(colorPresets, { preset ->
if (preset.id == "MORE") {
openOnlineStoreDialog("presets")
} else {
applyPresetFilter(preset) applyPresetFilter(preset)
updatePresetModeIcon(preset) updatePresetModeIcon(preset)
}
}, { preset -> }, { preset ->
openAdvancedSliders(preset) openAdvancedSliders(preset)
}) })
@@ -323,11 +348,15 @@ class MainActivity : AppCompatActivity() {
BackgroundOption("BG_01", "Nền 1", "backgrounds/background-01.png"), BackgroundOption("BG_01", "Nền 1", "backgrounds/background-01.png"),
BackgroundOption("BG_02", "Nền 2", "backgrounds/background-02.png"), BackgroundOption("BG_02", "Nền 2", "backgrounds/background-02.png"),
BackgroundOption("BG_03", "Nền 3", "backgrounds/background-03.png") BackgroundOption("BG_03", "Nền 3", "backgrounds/background-03.png")
) ) + loadDownloadedBackgrounds() + listOf(BackgroundOption("MORE", "Tải thêm", "MORE"))
backgroundOptionAdapter = BackgroundOptionAdapter(backgroundOptions) { option -> backgroundOptionAdapter = BackgroundOptionAdapter(backgroundOptions) { option ->
if (option.id == "MORE") {
openOnlineStoreDialog("backgrounds")
} else {
selectedBgAssetPath = option.assetPath selectedBgAssetPath = option.assetPath
updateBackgroundMode() updateBackgroundMode()
} }
}
binding.rvBackgroundOptions.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) binding.rvBackgroundOptions.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
binding.rvBackgroundOptions.adapter = backgroundOptionAdapter binding.rvBackgroundOptions.adapter = backgroundOptionAdapter
@@ -352,8 +381,13 @@ class MainActivity : AppCompatActivity() {
currentSelectedFramePath = null currentSelectedFramePath = null
} else { } else {
try { try {
val localFile = java.io.File(filesDir, fileName)
val bitmap = if (localFile.exists()) {
android.graphics.BitmapFactory.decodeFile(localFile.absolutePath)
} else {
val inputStream = assets.open(fileName) val inputStream = assets.open(fileName)
val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream) android.graphics.BitmapFactory.decodeStream(inputStream)
}
binding.imgFrameOverlay.setImageBitmap(bitmap) binding.imgFrameOverlay.setImageBitmap(bitmap)
currentSelectedFramePath = fileName currentSelectedFramePath = fileName
} catch (e: Exception) { } catch (e: Exception) {
@@ -747,7 +781,8 @@ class MainActivity : AppCompatActivity() {
val percentX = (event.x / v.width).coerceIn(0f, 1f) val percentX = (event.x / v.width).coerceIn(0f, 1f)
val percentY = (event.y / v.height).coerceIn(0f, 1f) val percentY = (event.y / v.height).coerceIn(0f, 1f)
// Cập nhật Class ID lấy nét từ điểm chạm // Cập nhật Class ID lấy nét từ điểm chạm (chỉ khi không sử dụng tính năng AI tách/xóa nền)
if (currentBackgroundMode != BackgroundMode.AI_BLUR) {
val mask = cachedCategoryMask val mask = cachedCategoryMask
val mW = cachedMaskWidth val mW = cachedMaskWidth
val mH = cachedMaskHeight val mH = cachedMaskHeight
@@ -757,8 +792,6 @@ class MainActivity : AppCompatActivity() {
userSelectedClassFocus = mask[maskY * mW + maskX].toInt() userSelectedClassFocus = mask[maskY * mW + maskX].toInt()
} }
// 1. Gửi lệnh Focus vật lý và đo sáng qua CameraX API (chỉ khi không sử dụng tính năng AI tách/xóa nền)
if (currentBackgroundMode != BackgroundMode.AI_BLUR) {
val cameraCtrl = this@MainActivity.cameraControl val cameraCtrl = this@MainActivity.cameraControl
if (cameraCtrl != null) { if (cameraCtrl != null) {
try { try {
@@ -787,6 +820,10 @@ class MainActivity : AppCompatActivity() {
.setDuration(900) .setDuration(900)
.withEndAction { focusRing.visibility = android.view.View.GONE } .withEndAction { focusRing.visibility = android.view.View.GONE }
.start() .start()
} else {
userSelectedClassFocus = -1
// Đảm bảo hủy bỏ lấy nét thấu kính vật lý để camera tự động lấy nét toàn cảnh rộng (AF liên tục)
cameraControl?.cancelFocusAndMetering()
} }
v.performClick() v.performClick()
@@ -1287,6 +1324,11 @@ class MainActivity : AppCompatActivity() {
cameraControl = camera.cameraControl cameraControl = camera.cameraControl
cameraInfo = camera.cameraInfo cameraInfo = camera.cameraInfo
if (currentBackgroundMode == BackgroundMode.AI_BLUR) {
userSelectedClassFocus = -1
cameraControl?.cancelFocusAndMetering()
}
// Reset zoom to 1.0x on start/switch // Reset zoom to 1.0x on start/switch
applyZoom(1.0f) applyZoom(1.0f)
} catch (xc: Exception) { } catch (xc: Exception) {
@@ -1770,40 +1812,17 @@ class MainActivity : AppCompatActivity() {
cachedMaskHeight = h cachedMaskHeight = h
categoryMaskBuffer.rewind() categoryMaskBuffer.rewind()
// Create and blur alpha mask with Touch-to-Focus class selection // Create and blur alpha mask (keep entire person sharp: all classes > 0)
val focusClassId = if (userSelectedClassFocus >= 0) userSelectedClassFocus else -1
val alphaMask = FloatArray(w * h) val alphaMask = FloatArray(w * h)
for (i in 0 until (w * h)) { for (i in 0 until (w * h)) {
val classId = if (i < bufferCapacity) rawBytes[i].toInt() else 0 val classId = if (i < bufferCapacity) rawBytes[i].toInt() else 0
alphaMask[i] = if (focusClassId >= 0) { alphaMask[i] = if (classId > 0) 1.0f else 0.0f
// Người dùng đã chạm chọn: chỉ lớp đúng class ID mới nét (Semantic Focus Plane)
if (classId == focusClassId) 1.0f else 0.0f
} else {
// Mặc định: Giữ nét toàn bộ Người (ID 1) và Phụ kiện cận cảnh (ID 2)
if (classId == 1 || classId == 2) 1.0f else 0.0f
}
} }
blurAlphaMaskBox(alphaMask, w, h, radius = 3) blurAlphaMaskBox(alphaMask, w, h, radius = 3)
// Prepare background (camera preview or custom background asset) // Prepare background (camera preview or custom background asset)
val baseBg = if (selectedBgAssetPath != null) { val baseBg = if (selectedBgAssetPath != null) {
try { loadBackgroundBitmap(selectedBgAssetPath!!, w, h)
val bgInputStream = assets.open(selectedBgAssetPath!!)
val decodedBg = android.graphics.BitmapFactory.decodeStream(bgInputStream)
if (decodedBg != null) {
val tempScaled = android.graphics.Bitmap.createScaledBitmap(decodedBg, w, h, true)
if (tempScaled != decodedBg) decodedBg.recycle()
tempScaled
} else {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
} catch (e: Exception) {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
} else { } else {
capturedImage capturedImage
} }
@@ -1885,40 +1904,17 @@ class MainActivity : AppCompatActivity() {
val h = categoryMask.height val h = categoryMask.height
val bufferCapacity = categoryMaskBuffer.capacity() val bufferCapacity = categoryMaskBuffer.capacity()
// Create and blur alpha mask with Touch-to-Focus class selection // Create and blur alpha mask (keep entire person sharp: all classes > 0)
val focusClassId = if (userSelectedClassFocus >= 0) userSelectedClassFocus else -1
val alphaMask = FloatArray(w * h) val alphaMask = FloatArray(w * h)
for (i in 0 until (w * h)) { for (i in 0 until (w * h)) {
val classId = if (i < bufferCapacity) categoryMaskBuffer.get(i).toInt() else 0 val classId = if (i < bufferCapacity) categoryMaskBuffer.get(i).toInt() else 0
alphaMask[i] = if (focusClassId >= 0) { alphaMask[i] = if (classId > 0) 1.0f else 0.0f
// Người dùng đã chạm chọn: chỉ lớp đúng class ID mới nét (Semantic Focus Plane)
if (classId == focusClassId) 1.0f else 0.0f
} else {
// Mặc định: Giữ nét toàn bộ Người (ID 1) và Phụ kiện cận cảnh (ID 2)
if (classId == 1 || classId == 2) 1.0f else 0.0f
}
} }
blurAlphaMaskBox(alphaMask, w, h, radius = 5) blurAlphaMaskBox(alphaMask, w, h, radius = 5)
// Prepare background (camera preview or custom background asset) // Prepare background (camera preview or custom background asset)
val baseBg = if (selectedBgAssetPath != null) { val baseBg = if (selectedBgAssetPath != null) {
try { loadBackgroundBitmap(selectedBgAssetPath!!, w, h)
val bgInputStream = assets.open(selectedBgAssetPath!!)
val decodedBg = android.graphics.BitmapFactory.decodeStream(bgInputStream)
if (decodedBg != null) {
val tempScaled = android.graphics.Bitmap.createScaledBitmap(decodedBg, w, h, true)
if (tempScaled != decodedBg) decodedBg.recycle()
tempScaled
} else {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
} catch (e: Exception) {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
} else { } else {
// Triệt tiêu Ghost Shadow (Bóng ma rế viền) bằng giãn cơ thể ẩn (Inpainting Dilate) // Triệt tiêu Ghost Shadow (Bóng ma rế viền) bằng giãn cơ thể ẩn (Inpainting Dilate)
val cleanBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888) val cleanBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
@@ -2317,11 +2313,11 @@ class MainActivity : AppCompatActivity() {
val h = capturedImage.height val h = capturedImage.height
val alphaMask = FloatArray(w * h) val alphaMask = FloatArray(w * h)
// Giả lập mặt nạ bằng hình bầu dục lấy nét mềm ở trung tâm (mô phỏng chân dung) // Giả lập mặt nạ bằng hình bầu dục lấy nét mềm ở trung tâm (Tối ưu hóa đẩy tâm lên cao và mở rộng bán kính để giữ nét hoàn toàn cho khuôn mặt và đầu tóc người chụp)
val cx = w * 0.5f val cx = w * 0.5f
val cy = h * 0.52f val cy = h * 0.40f
val rx = w * 0.28f val rx = w * 0.40f
val ry = h * 0.38f val ry = h * 0.50f
for (y in 0 until h) { for (y in 0 until h) {
val dy = (y - cy) / ry val dy = (y - cy) / ry
val rowOffset = y * w val rowOffset = y * w
@@ -2339,23 +2335,7 @@ class MainActivity : AppCompatActivity() {
// TẠO HÌNH NỀN (Background Canvas) // TẠO HÌNH NỀN (Background Canvas)
val baseBg = if (selectedBgAssetPath != null) { val baseBg = if (selectedBgAssetPath != null) {
try { loadBackgroundBitmap(selectedBgAssetPath!!, w, h)
val bgInputStream = assets.open(selectedBgAssetPath!!)
val decodedBg = android.graphics.BitmapFactory.decodeStream(bgInputStream)
if (decodedBg != null) {
val tempScaled = android.graphics.Bitmap.createScaledBitmap(decodedBg, w, h, true)
if (tempScaled != decodedBg) decodedBg.recycle()
tempScaled
} else {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
} catch (e: Exception) {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
} else { } else {
// Triệt tiêu Ghost Shadow bằng Inpainting Dilate // Triệt tiêu Ghost Shadow bằng Inpainting Dilate
val cleanBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888) val cleanBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
@@ -2428,6 +2408,372 @@ class MainActivity : AppCompatActivity() {
return outputBitmap return outputBitmap
} }
private fun loadBackgroundBitmap(path: String, w: Int, h: Int): android.graphics.Bitmap {
try {
val localFile = java.io.File(filesDir, path)
val decodedBg = if (localFile.exists()) {
android.graphics.BitmapFactory.decodeFile(localFile.absolutePath)
} else {
val bgInputStream = assets.open(path)
android.graphics.BitmapFactory.decodeStream(bgInputStream)
}
if (decodedBg != null) {
val tempScaled = android.graphics.Bitmap.createScaledBitmap(decodedBg, w, h, true)
if (tempScaled != decodedBg) decodedBg.recycle()
return tempScaled
}
} catch (e: Exception) {
e.printStackTrace()
}
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
return solidBg
}
private fun loadDownloadedFrames(): List<FrameItem> {
val list = mutableListOf<FrameItem>()
val dir = java.io.File(filesDir, "downloaded_frames")
if (dir.exists() && dir.isDirectory) {
dir.listFiles()?.forEach { file ->
if (file.isFile && file.name.endsWith(".png")) {
val id = file.nameWithoutExtension
val name = id.replace("_", " ").replaceFirstChar { if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() }
list.add(FrameItem(id, name, "downloaded_frames/${file.name}"))
}
}
}
return list
}
private fun loadDownloadedBackgrounds(): List<BackgroundOption> {
val list = mutableListOf<BackgroundOption>()
val dir = java.io.File(filesDir, "downloaded_backgrounds")
if (dir.exists() && dir.isDirectory) {
dir.listFiles()?.forEach { file ->
if (file.isFile && (file.name.endsWith(".jpg") || file.name.endsWith(".png") || file.name.endsWith(".jpeg"))) {
val id = file.nameWithoutExtension
val name = id.replace("_", " ").replaceFirstChar { if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() }
list.add(BackgroundOption(id, name, "downloaded_backgrounds/${file.name}"))
}
}
}
return list
}
private var storeDialog: androidx.appcompat.app.AlertDialog? = null
private fun getServerUrl(): String {
val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
return prefs.getString("server_url", "http://10.0.2.2:8080") ?: "http://10.0.2.2:8080"
}
private fun saveServerUrl(url: String) {
val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
prefs.edit().putString("server_url", url).apply()
}
private fun openOnlineStoreDialog(defaultTab: String = "frames") {
val dialogView = layoutInflater.inflate(R.layout.dialog_online_store, null)
val etServerUrl = dialogView.findViewById<android.widget.EditText>(R.id.etServerUrl)
val btnConnectServer = dialogView.findViewById<android.widget.Button>(R.id.btnConnectServer)
val btnTabFrames = dialogView.findViewById<android.widget.Button>(R.id.btnTabFrames)
val btnTabBackgrounds = dialogView.findViewById<android.widget.Button>(R.id.btnTabBackgrounds)
val btnTabPresets = dialogView.findViewById<android.widget.Button>(R.id.btnTabPresets)
val rvStoreAssets = dialogView.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.rvStoreAssets)
val progressLoadingStore = dialogView.findViewById<android.widget.ProgressBar>(R.id.progressLoadingStore)
val layoutStoreError = dialogView.findViewById<android.widget.LinearLayout>(R.id.layoutStoreError)
val btnStoreRetry = dialogView.findViewById<android.widget.Button>(R.id.btnStoreRetry)
val btnStoreClose = dialogView.findViewById<android.widget.ImageButton>(R.id.btnStoreClose)
etServerUrl.setText(getServerUrl())
var currentTab = defaultTab
var allFetchedItems = listOf<OnlineStoreItem>()
val storeAdapter = StoreItemAdapter(listOf(), filesDir) { storeItem ->
if (storeItem.downloadState == DownloadState.DOWNLOADED) {
applyDownloadedAsset(storeItem)
storeDialog?.dismiss()
} else if (storeItem.downloadState == DownloadState.NOT_DOWNLOADED) {
downloadAsset(storeItem, rvStoreAssets.adapter as StoreItemAdapter)
}
}
rvStoreAssets.layoutManager = LinearLayoutManager(this)
rvStoreAssets.adapter = storeAdapter
fun updateTabButtons() {
val primaryColor = ContextCompat.getColor(this, R.color.theme_primary)
val cardColor = ContextCompat.getColor(this, R.color.theme_item_card_bg)
val darkColor = ContextCompat.getColor(this, R.color.theme_panel_dark)
val whiteColor = ContextCompat.getColor(this, R.color.theme_white)
btnTabFrames.setBackgroundColor(if (currentTab == "frames") primaryColor else cardColor)
btnTabFrames.setTextColor(if (currentTab == "frames") darkColor else whiteColor)
btnTabBackgrounds.setBackgroundColor(if (currentTab == "backgrounds") primaryColor else cardColor)
btnTabBackgrounds.setTextColor(if (currentTab == "backgrounds") darkColor else whiteColor)
btnTabPresets.setBackgroundColor(if (currentTab == "presets") primaryColor else cardColor)
btnTabPresets.setTextColor(if (currentTab == "presets") darkColor else whiteColor)
val filtered = allFetchedItems.filter { it.category == currentTab }
storeAdapter.updateItems(filtered)
}
fun fetchManifest() {
val urlStr = etServerUrl.text.toString().trim()
if (urlStr.isEmpty()) {
Toast.makeText(this, "Vui lòng nhập địa chỉ server", Toast.LENGTH_SHORT).show()
return
}
saveServerUrl(urlStr)
progressLoadingStore.visibility = android.view.View.VISIBLE
rvStoreAssets.visibility = android.view.View.GONE
layoutStoreError.visibility = android.view.View.GONE
Thread {
try {
val url = java.net.URL("$urlStr/manifest.json")
val connection = url.openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 5000
connection.readTimeout = 5000
val content = connection.inputStream.bufferedReader().use { it.readText() }
val json = org.json.JSONObject(content)
val itemsList = mutableListOf<OnlineStoreItem>()
if (json.has("frames")) {
val arr = json.getJSONArray("frames")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
itemsList.add(OnlineStoreItem(
id = obj.getString("id"),
name = obj.getString("name"),
url = obj.getString("url"),
version = obj.optInt("version", 1),
category = "frames"
))
}
}
if (json.has("backgrounds")) {
val arr = json.getJSONArray("backgrounds")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
itemsList.add(OnlineStoreItem(
id = obj.getString("id"),
name = obj.getString("name"),
url = obj.getString("url"),
version = obj.optInt("version", 1),
category = "backgrounds"
))
}
}
if (json.has("presets")) {
val arr = json.getJSONArray("presets")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
itemsList.add(OnlineStoreItem(
id = obj.getString("id"),
name = obj.getString("name"),
url = obj.getString("url"),
version = obj.optInt("version", 1),
category = "presets"
))
}
}
runOnUiThread {
allFetchedItems = itemsList
progressLoadingStore.visibility = android.view.View.GONE
rvStoreAssets.visibility = android.view.View.VISIBLE
updateTabButtons()
}
} catch (e: Exception) {
e.printStackTrace()
runOnUiThread {
progressLoadingStore.visibility = android.view.View.GONE
layoutStoreError.visibility = android.view.View.VISIBLE
Toast.makeText(this, "Vui lòng kết nối mạng để tải thêm tài nguyên", Toast.LENGTH_LONG).show()
}
}
}.start()
}
btnConnectServer.setOnClickListener { fetchManifest() }
btnStoreRetry.setOnClickListener { fetchManifest() }
btnTabFrames.setOnClickListener {
currentTab = "frames"
updateTabButtons()
}
btnTabBackgrounds.setOnClickListener {
currentTab = "backgrounds"
updateTabButtons()
}
btnTabPresets.setOnClickListener {
currentTab = "presets"
updateTabButtons()
}
btnStoreClose.setOnClickListener { storeDialog?.dismiss() }
fetchManifest()
storeDialog = androidx.appcompat.app.AlertDialog.Builder(this)
.setView(dialogView)
.create()
storeDialog?.window?.setBackgroundDrawableResource(android.R.color.transparent)
storeDialog?.show()
}
private fun downloadAsset(item: OnlineStoreItem, adapter: StoreItemAdapter) {
item.downloadState = DownloadState.DOWNLOADING
adapter.notifyDataSetChanged()
Thread {
try {
val url = java.net.URL(item.url)
val targetDir = when (item.category) {
"frames" -> java.io.File(filesDir, "downloaded_frames")
"backgrounds" -> java.io.File(filesDir, "downloaded_backgrounds")
else -> filesDir
}
if (!targetDir.exists()) targetDir.mkdirs()
val ext = item.url.substringAfterLast(".", "png")
val fileName = if (item.category == "presets") "${item.id}.json" else "${item.id}.$ext"
val outputFile = java.io.File(targetDir, fileName)
url.openStream().use { input ->
outputFile.outputStream().use { output ->
input.copyTo(output)
}
}
runOnUiThread {
item.downloadState = DownloadState.DOWNLOADED
adapter.notifyDataSetChanged()
Toast.makeText(this, "Tải thành công: ${item.name}", Toast.LENGTH_SHORT).show()
reloadLocalAssets()
}
} catch (e: Exception) {
e.printStackTrace()
runOnUiThread {
item.downloadState = DownloadState.NOT_DOWNLOADED
adapter.notifyDataSetChanged()
Toast.makeText(this, "Tải thất bại: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}.start()
}
private fun reloadLocalAssets() {
allFrames = listOf(
FrameItem("DEFAULT", "Mặc định", ""),
FrameItem("instax_mini_single", "Instax Mini", "frames/instaxframe.png")
) + loadDownloadedFrames() + listOf(FrameItem("MORE", "Tải thêm", ""))
frameItemAdapter.updateItems(allFrames)
backgroundOptions = listOf(
BackgroundOption("NONE", "Mặc định", null),
BackgroundOption("BG_01", "Nền 1", "backgrounds/background-01.png"),
BackgroundOption("BG_02", "Nền 2", "backgrounds/background-02.png"),
BackgroundOption("BG_03", "Nền 3", "backgrounds/background-03.png")
) + loadDownloadedBackgrounds() + listOf(BackgroundOption("MORE", "Tải thêm", "MORE"))
backgroundOptionAdapter = BackgroundOptionAdapter(backgroundOptions) { option ->
if (option.id == "MORE") {
openOnlineStoreDialog("backgrounds")
} else {
selectedBgAssetPath = option.assetPath
updateBackgroundMode()
}
}
binding.rvBackgroundOptions.adapter = backgroundOptionAdapter
val defaultPresets = listOf(
ColorPreset(
id = "DEFAULT",
name = "Mặc định",
themeCategory = "None",
isEditable = false,
basic = BasicAdjustments(0f, 0f, 0f, 0f, 0f, 0f),
advanced = AdvancedEffects(0f, 0f, 0f),
toneCurve = ToneCurveEmulation(0f, 0f, 0f, 0f),
grain = FilmGrain(0f, 0f)
),
ColorPreset(
id = "instax_faded_warm_01",
name = "Instax Nắng Chiều",
themeCategory = "Retro Vintage",
isEditable = true,
basic = BasicAdjustments(0.05f, -0.15f, -0.20f, 0.25f, 0.18f, -0.02f),
advanced = AdvancedEffects(-0.20f, -0.15f, 0.35f),
toneCurve = ToneCurveEmulation(0.12f, 0.0f, 0.04f, 0.08f),
grain = FilmGrain(0.28f, 0.15f)
),
ColorPreset(
id = "instax_bw_cool",
name = "Instax Trắng Đen",
themeCategory = "Monochrome",
isEditable = true,
basic = BasicAdjustments(0.0f, 0.10f, -1.0f, 0.0f, -0.05f, 0.0f),
advanced = AdvancedEffects(0.05f, 0.0f, 0.20f),
toneCurve = ToneCurveEmulation(0.08f, 0.0f, 0.0f, 0.0f),
grain = FilmGrain(0.35f, 0.18f)
),
ColorPreset(
id = "instax_cool_summer",
name = "Mùa Hè Dịu Mát",
themeCategory = "Summer",
isEditable = true,
basic = BasicAdjustments(0.02f, -0.05f, -0.10f, 0.15f, -0.18f, 0.05f),
advanced = AdvancedEffects(-0.10f, -0.05f, 0.15f),
toneCurve = ToneCurveEmulation(0.05f, 0.02f, 0.05f, 0.10f),
grain = FilmGrain(0.15f, 0.12f)
)
)
colorPresets = defaultPresets + loadCustomPresets() + listOf(
ColorPreset(
id = "MORE",
name = "Tải thêm",
themeCategory = "None",
isEditable = false,
basic = BasicAdjustments(0f, 0f, 0f, 0f, 0f, 0f),
advanced = AdvancedEffects(0f, 0f, 0f),
toneCurve = ToneCurveEmulation(0f, 0f, 0f, 0f),
grain = FilmGrain(0f, 0f)
)
)
presetAdapter.updatePresets(colorPresets)
}
private fun applyDownloadedAsset(item: OnlineStoreItem) {
when (item.category) {
"frames" -> {
val ext = item.url.substringAfterLast(".", "png")
val path = "downloaded_frames/${item.id}.$ext"
setFrameOverlay(path)
frameItemAdapter.selectItem(item.id)
}
"backgrounds" -> {
val ext = item.url.substringAfterLast(".", "png")
val path = "downloaded_backgrounds/${item.id}.$ext"
selectedBgAssetPath = path
updateBackgroundMode()
backgroundOptionAdapter.selectItem(item.id)
}
"presets" -> {
val targetPreset = colorPresets.find { it.id == item.id }
if (targetPreset != null) {
applyPresetFilter(targetPreset)
updatePresetModeIcon(targetPreset)
presetAdapter.selectItem(item.id)
}
}
}
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
cameraExecutor.shutdown() cameraExecutor.shutdown()
@@ -39,6 +39,9 @@ class PresetAdapter(
if (preset.id == "DEFAULT") { if (preset.id == "DEFAULT") {
holder.viewPresetColor.setBackgroundResource(R.drawable.ic_block_white) holder.viewPresetColor.setBackgroundResource(R.drawable.ic_block_white)
holder.txtPresetName.text = holder.itemView.context.getString(R.string.default_frame) holder.txtPresetName.text = holder.itemView.context.getString(R.string.default_frame)
} else if (preset.id == "MORE") {
holder.viewPresetColor.setBackgroundResource(R.drawable.ic_more)
holder.txtPresetName.text = "Tải thêm"
} else { } else {
val colorHex = when (preset.id) { val colorHex = when (preset.id) {
"instax_faded_warm_01" -> "#FFF57C00" "instax_faded_warm_01" -> "#FFF57C00"
@@ -125,4 +128,14 @@ class PresetAdapter(
notifyItemChanged(previousSelected) notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition) notifyItemChanged(selectedPosition)
} }
fun selectItem(presetId: String) {
val index = presets.indexOfFirst { it.id == presetId }
if (index != -1 && index != selectedPosition) {
val oldPos = selectedPosition
selectedPosition = index
notifyItemChanged(oldPos)
notifyItemChanged(selectedPosition)
}
}
} }
@@ -0,0 +1,121 @@
package com.photobooth.app
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import java.io.File
data class OnlineStoreItem(
val id: String,
val name: String,
val url: String,
val version: Int,
val category: String, // "frames", "backgrounds", "presets"
var downloadState: DownloadState = DownloadState.NOT_DOWNLOADED
)
enum class DownloadState {
NOT_DOWNLOADED,
DOWNLOADING,
DOWNLOADED
}
class StoreItemAdapter(
private var items: List<OnlineStoreItem>,
private val filesDir: File,
private val onActionClick: (OnlineStoreItem) -> Unit
) : RecyclerView.Adapter<StoreItemAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val imgThumb: ImageView = view.findViewById(R.id.imgStoreAssetThumb)
val txtName: TextView = view.findViewById(R.id.txtStoreAssetName)
val btnAction: Button = view.findViewById(R.id.btnStoreAssetAction)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_online_asset, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.txtName.text = item.name
// Set icon based on category
when (item.category) {
"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)
else -> holder.imgThumb.setImageResource(R.drawable.ic_image_gallery)
}
// Determine if downloaded
val isDownloaded = isAssetDownloaded(item)
if (isDownloaded) {
item.downloadState = DownloadState.DOWNLOADED
} else if (item.downloadState != DownloadState.DOWNLOADING) {
item.downloadState = DownloadState.NOT_DOWNLOADED
}
// Update button appearance based on state
when (item.downloadState) {
DownloadState.NOT_DOWNLOADED -> {
holder.btnAction.text = "Tải về"
holder.btnAction.isEnabled = true
holder.btnAction.setBackgroundColor(
androidx.core.content.ContextCompat.getColor(holder.itemView.context, R.color.theme_primary)
)
}
DownloadState.DOWNLOADING -> {
holder.btnAction.text = "Đang tải..."
holder.btnAction.isEnabled = false
holder.btnAction.setBackgroundColor(
androidx.core.content.ContextCompat.getColor(holder.itemView.context, R.color.theme_item_card_bg)
)
}
DownloadState.DOWNLOADED -> {
holder.btnAction.text = "Áp dụng"
holder.btnAction.isEnabled = true
holder.btnAction.setBackgroundColor(
android.graphics.Color.parseColor("#4CAF50") // Green for Apply
)
}
}
holder.btnAction.setOnClickListener {
onActionClick(item)
}
}
override fun getItemCount(): Int = items.size
fun updateItems(newItems: List<OnlineStoreItem>) {
items = newItems
notifyDataSetChanged()
}
private fun isAssetDownloaded(item: OnlineStoreItem): Boolean {
return when (item.category) {
"frames" -> {
val ext = item.url.substringAfterLast(".", "png")
val localFile = File(filesDir, "downloaded_frames/${item.id}.$ext")
localFile.exists()
}
"backgrounds" -> {
val ext = item.url.substringAfterLast(".", "png")
val localFile = File(filesDir, "downloaded_backgrounds/${item.id}.$ext")
localFile.exists()
}
"presets" -> {
val localFile = File(filesDir, "${item.id}.json")
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="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>
@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/theme_sub_bar_bg"
android:padding="16dp">
<!-- Header Section -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/tvStoreTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:text="Cửa Hàng Tài Nguyên"
android:textColor="@color/theme_white"
android:textSize="18sp"
android:textStyle="bold" />
<ImageButton
android:id="@+id/btnStoreClose"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_close_white"
android:contentDescription="Close Store" />
</RelativeLayout>
<!-- Editable Cloud Server URL Input -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@color/theme_item_card_bg"
android:padding="8dp"
android:layout_marginBottom="16dp"
android:gravity="center_vertical">
<EditText
android:id="@+id/etServerUrl"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="Địa chỉ Cloud Server"
android:textColorHint="#88FFFFFF"
android:textColor="@color/theme_white"
android:textSize="14sp"
android:inputType="textUri"
android:background="@android:color/transparent"
android:text="http://10.0.2.2:8080" />
<Button
android:id="@+id/btnConnectServer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Kết nối"
android:textSize="12sp"
android:textColor="@color/theme_panel_dark"
android:backgroundTint="@color/theme_primary"
android:paddingHorizontal="12dp"
android:minHeight="36dp" />
</LinearLayout>
<!-- Custom Category Tabs -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp"
android:weightSum="3">
<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:backgroundTint="@color/theme_primary"
android:textColor="@color/theme_panel_dark"
android:paddingHorizontal="2dp"
android:minHeight="40dp" />
<Button
android:id="@+id/btnTabBackgrounds"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="2dp"
android:text="Phông nền"
android:textSize="12sp"
android:backgroundTint="@color/theme_item_card_bg"
android:textColor="@color/theme_white"
android:paddingHorizontal="2dp"
android:minHeight="40dp" />
<Button
android:id="@+id/btnTabPresets"
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:backgroundTint="@color/theme_item_card_bg"
android:textColor="@color/theme_white"
android:paddingHorizontal="2dp"
android:minHeight="40dp" />
</LinearLayout>
<!-- List & Loading Container -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="250dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvStoreAssets"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible" />
<ProgressBar
android:id="@+id/progressLoadingStore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminateTint="@color/theme_primary"
android:visibility="gone" />
<!-- Error Layout -->
<LinearLayout
android:id="@+id/layoutStoreError"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Vui lòng kết nối mạng để tải thêm tài nguyên"
android:textColor="#E57373"
android:textSize="14sp"
android:gravity="center"
android:layout_marginBottom="12dp" />
<Button
android:id="@+id/btnStoreRetry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Thử lại"
android:textColor="@color/theme_panel_dark"
android:backgroundTint="@color/theme_primary" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@color/theme_item_card_bg"
android:layout_marginVertical="4dp"
android:padding="8dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="48dp"
android:layout_height="48dp"
app:cardCornerRadius="4dp"
app:strokeWidth="1dp"
app:strokeColor="#44FFFFFF"
android:layout_marginEnd="12dp"
app:cardBackgroundColor="@color/theme_panel_dark">
<ImageView
android:id="@+id/imgStoreAssetThumb"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@drawable/ic_image_gallery" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/txtStoreAssetName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Asset Name"
android:textColor="@color/theme_white"
android:textSize="14sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end" />
<Button
android:id="@+id/btnStoreAssetAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Tải về"
android:textSize="11sp"
android:textColor="@color/theme_panel_dark"
android:backgroundTint="@color/theme_primary"
android:paddingHorizontal="16dp"
android:minHeight="36dp"
android:minWidth="90dp" />
</LinearLayout>
+22
View File
@@ -0,0 +1,22 @@
{
"id": "preset_fuji_cloud",
"name": "Fuji Retro (Cloud)",
"theme_category": "Retro Vintage",
"brightness": 0.1,
"contrast": -0.05,
"highlight": -0.1,
"shadow": 0.1,
"saturation": -0.15,
"vibrance": 0.2,
"temperature": 0.15,
"tint": -0.05,
"clarity": -0.1,
"dehaze": -0.05,
"vignette_amount": 0.3,
"faded_black_level": 0.1,
"shadows_tint_r": 0.05,
"shadows_tint_g": 0.0,
"shadows_tint_b": 0.08,
"grain_amount": 0.25,
"grain_size": 0.15
}
+26
View File
@@ -0,0 +1,26 @@
{
"frames": [
{
"id": "frame_vintage_cloud",
"name": "Vintage Gold (Cloud)",
"url": "http://10.0.2.2:8080/assets/frames/instaxframe.png",
"version": 1
}
],
"backgrounds": [
{
"id": "bg_neon_cloud",
"name": "Neon Pink (Cloud)",
"url": "http://10.0.2.2:8080/assets/backgrounds/background-01.png",
"version": 1
}
],
"presets": [
{
"id": "preset_fuji_cloud",
"name": "Fuji Retro (Cloud)",
"url": "http://10.0.2.2:8080/fuji_retro.json",
"version": 1
}
]
}