feat: thêm tính năng tải frame, presets, background từ cloud
This commit is contained in:
+106
@@ -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** và **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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user