Implement 7_UX_BUTTONS: Restructured selections horizontal timeline layers to overlap behind translated anchor buttons, added long-click haptic feedback edit shortcut and custom sliders dialog to save custom JSON presets

This commit is contained in:
2026-07-05 17:27:10 +07:00
parent edd4c2946d
commit 4a33140c67
8 changed files with 876 additions and 121 deletions
+144
View File
@@ -0,0 +1,144 @@
# 🎨 KẾ HOẠCH TÁI CẤU TRÚC UX: HOẠT HỌA DI CHUYỂN & DANH SÁCH CUỘN ĐỒNG HÀNG
Mục tiêu: Khi kích hoạt Frame hoặc Preset, nút đại diện sẽ dịch chuyển sang trái làm điểm neo. Danh sách các item sẽ trượt trên cùng một hàng ngang và chui xuống dưới nút neo khi lướt (Swipe). Nhấn giữ preset để mở shortcut chỉnh sửa.
---
## 📐 1. Cấu Trúc Lại XML Layout Tuyến Tính (`activity_main.xml`)
Để đạt được hiệu ứng cuộn "chui xuống dưới nút", nút Neo (Anchor) phải có thứ tự hiển thị nằm **trên** (Z-index cao hơn) và nằm đè lên `RecyclerView`. Ta sẽ sử dụng một `FrameLayout` hoặc `ConstraintLayout` để làm việc này tại khu vực viền xanh lá của file `image_2a8660.png`.
```xml
<!-- KHU VỰC ĐIỀU KHIỂN DÒNG (Khung viền xanh trong image_2a8660.png) -->
<FrameLayout
android:id="@+id/panelSelectionContainer"
android:layout_width="match_parent"
android:layout_height="70dp"
android:background="#000000"
app:layout_constraintTop_toBottomOf="@id/cameraContainer"
app:layout_constraintBottom_toTopOf="@id/zoomControls">
<!-- LỚP ĐÁY: Danh sách cuộn ngang dùng chung cho cả Frame và Preset -->
<!-- Thuộc tính paddingStart giúp phần tử đầu tiên không bị nút Neo che khuất lúc khởi tạo -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvHorizontalTimeline"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:clipToPadding="false"
android:paddingStart="80dp"
android:paddingEnd="16dp"
android:visibility="gone" />
<!-- LỚP TRÊN: Chứa 2 nút chức năng chính (Gốc ở giữa, động chuyển sang trái) -->
<ConstraintLayout
android:id="@+id/buttonsWrapper"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- Nút Frame -->
<ImageView
android:id="@+id/btnMainFrame"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@drawable/ic_frame_default"
android:background="@null"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnMainPreset"
app:layout_constraintHorizontal_chainStyle="packed"
android:layout_marginEnd="16dp"/>
<!-- Nút Presets Màu -->
<ImageView
android:id="@+id/btnMainPreset"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@drawable/ic_palette_default"
android:background="@null"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/btnMainFrame"
app:layout_constraintEnd_toEndOf="parent" />
</ConstraintLayout>
</FrameLayout>
```
---
## 💻 2. Kịch Bản Hoạt Họa & Điều Khiển Trạng Thái (Kotlin)
### 🔹 2.1. Logic Di Chuyển Nút Ra Cạnh Trái (Animation)
Khi người dùng nhấn vào nút Frame, ta sẽ chạy một Animation dịch chuyển nút này về sát rìa trái màn hình, đồng thời ẩn nút còn lại và hiển thị `RecyclerView`.
```kotlin
private var isFrameModeActive = false
fun toggleFrameMode(activate: Boolean) {
if (activate) {
isFrameModeActive = true
// 1. Ẩn nút Preset màu đi
binding.btnMainPreset.animate().alpha(0f).setDuration(200).withEndAction {
binding.btnMainPreset.visibility = View.GONE
}
// 2. Dịch chuyển nút Frame sang trái (Dùng TransitionManager để mượt mà)
TransitionManager.beginDelayedTransition(binding.buttonsWrapper)
val params = binding.btnMainFrame.layoutParams as ConstraintLayout.LayoutParams
params.endToStart = ConstraintLayout.LayoutParams.UNSET // Hủy liên kết xích giữa
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
binding.btnMainFrame.layoutParams = params
// 3. Hiển thị RecyclerView cùng hàng
binding.rvHorizontalTimeline.apply {
adapter = frameAdapter // Đổi sang dữ liệu của Frame
visibility = View.VISIBLE
alpha = 0f
animate().alpha(1f).setDuration(300).start()
}
} else {
// Logic phục hồi về trạng thái cân bằng ở giữa ban đầu như trong ảnh image_2a8660.png
}
}
```
> **Giải thích cơ chế "Chui xuống dưới":** Do `rvHorizontalTimeline` được khai báo trước trong `FrameLayout`, nó nằm ở lớp dưới. Nút `btnMainFrame` nằm ở lớp trên (`ConstraintLayout`). Khi bạn vuốt cuộn ngang `RecyclerView`, các item bên trong sẽ tự nhiên trượt và chui xuống dưới khu vực nút Frame đang đứng cố định ở góc trái.
---
## 🔄 3. Cơ Chế Nhấn Giữ Preset & Nút Chỉnh Sửa Nhỏ (Edit Shortcut)
Để giải quyết yêu cầu nhấn giữ mở nút Edit nhỏ mà không làm thay đổi preset gốc, chúng ta sẽ áp dụng cơ chế **Floating Context Button**.
### 🔹 3.1. Thiết kế Giao Diện Nút Edit Nhỏ trong Item của RecyclerView
Trong file layout thiết kế cho từng ô Preset nhỏ (`item_preset.xml`), ta thêm một nút icon cây bút nhỏ (`btnSmallEdit`) nằm đè ở góc trên bên phải của ô. Mặc định nút này sẽ ẩn (`android:visibility="gone"`).
### 🔹 3.2. Code bắt sự kiện Nhấn giữ (OnLongClickListener)
1. **Bước 1: Hiện nút Edit nhỏ:** Nhấn giữ Item.
Trong Adapter của Preset, gán sự kiện `setOnLongClickListener` cho ô màu. Khi kích hoạt, hiển thị nút `btnSmallEdit` (`View.VISIBLE`) kèm hiệu ứng rung nhẹ (Haptic Feedback) để thông báo cho người dùng.
2. **Bước 2: Mở bảng chỉnh sửa độc lập:** Chạm nút Edit.
Khi người dùng bấm vào nút `btnSmallEdit` nhỏ vừa hiện ra, app sẽ nhân bản (Clone) đối tượng `ColorPreset` hiện tại thành một thực thể tạm thời và mở bảng điều khiển Sliders (Vibrance, Clarity, Dehaze...).
3. **Bước 3: Lưu thành Preset mới:** Đặt tên & Ghi file.
Sau khi tinh chỉnh xong và ấn Save, một Dialog hiện lên yêu cầu nhập tên mới (Ví dụ: "Instax của Lộc"). App lưu file JSON mới này vào bộ nhớ riêng (`context.filesDir`) và giữ nguyên vẹn file preset gốc ban đầu.
---
## 📅 4. Các Đầu Việc Cần Làm Ngay (Checklist)
* [ ] **Cập nhật lại Code Layout:** Thiết lập `FrameLayout` bao bọc bên ngoài đúng như cấu trúc để tạo lớp đè cho hiệu ứng chui dưới nút.
* [ ] **Tạo File Hoạt Họa/Transition:** Viết logic hoàn chỉnh cho cả việc lật ngược trạng thái (khi người dùng bấm vào nút Neo lần nữa để đóng timeline, đưa 2 nút quay lại vị trí trung tâm cân bằng như ảnh `image_2a8660.png`).
* [ ] **Cập nhật ViewHolder trong Adapter:** Thêm tính năng ẩn/hiện nút `btnSmallEdit` dựa trên trạng thái click của từng item đơn lẻ.
+195
View File
@@ -0,0 +1,195 @@
# 🎨 KẾ HOẠCH PHÁT TRIỂN: BẢNG SLIDERS TÙY CHỈNH MÀU THỜI GIAN THỰC (REAL-TIME OVERLAY)
Mục tiêu: Khi bấm nút Edit nhỏ trên một Preset, một bảng chỉnh sửa mờ (Semi-transparent Overlay) sẽ trượt lên từ phía dưới, đè lên kính ngắm camera. Mọi thao tác kéo trượt của người dùng sẽ cập nhật trực tiếp vào bộ xử lý đồ họa phần cứng để thay đổi màu ảnh ngay lập tức (WYSIWYG).
---
## 📐 1. Cấu Trúc Thành Phần Giao Diện (XML Layout)
Để bảng điều khiển này có thể phủ (Overlay) lên hình ảnh camera mà không làm dịch chuyển hay thu nhỏ kính ngắm, chúng ta sẽ định nghĩa nó như một lớp trên cùng nằm trong `FrameLayout` chính của ứng dụng.
### 🔹 1.1. Bổ sung giao diện ẩn vào `activity_main.xml`
Bảng này mặc định sẽ ẩn đi (`android:visibility="gone"`) và được gom nhóm trong một thẻ `NestedScrollView` để người dùng có thể cuộn lên/xuống nếu có nhiều thông số.
```xml
<!-- Chèn thêm vào phân tầng cao nhất của FrameLayout chứa camera hoặc Layout gốc -->
<LinearLayout
android:id="@+id/layoutAdvancedSliders"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="#D9000000" <!-- Nn đen m 85% đ va nhìn thy camera phía sau va rõ ch -->
android:padding="20dp"
android:clickable="true"
android:focusable="true"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/panelSelectionContainer">
<!-- Thanh tiêu đề & Nút thao tác nhanh -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/tvEditPresetName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Chỉnh sửa: Instax Warm"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="16sp" />
<ImageButton
android:id="@+id/btnCancelEdit"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_alignParentEnd="true"
android:background="@null"
android:src="@drawable/ic_close_white" />
</RelativeLayout>
<!-- KHU VỰC CÁC THANH KÉO (SLIDERS) -->
<!-- 1. Thanh chỉnh VIBRANCE -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Vibrance (Bão hòa thông minh)"
android:textColor="#CCCCCC"
android:textSize="12sp" />
<com.google.android.material.slider.Slider
android:id="@+id/sliderVibrance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="-1.0"
android:valueTo="1.0"
android:value="0.0"
app:thumbColor="#FF9800"
app:trackColorActive="#FF9800"
app:trackColorInactive="#333333" />
<!-- 2. Thanh chỉnh CLARITY -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Clarity (Độ rõ nét vùng trung tính)"
android:textColor="#CCCCCC"
android:textSize="12sp"
android:layout_marginTop="8dp" />
<com.google.android.material.slider.Slider
android:id="@+id/sliderClarity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="-1.0"
android:valueTo="1.0"
android:value="0.0"
app:thumbColor="#FF9800"
app:trackColorActive="#FF9800"
app:trackColorInactive="#333333" />
<!-- 3. Thanh chỉnh DEHAZE -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Dehaze (Độ trong suốt / Sương mù)"
android:textColor="#CCCCCC"
android:textSize="12sp"
android:layout_marginTop="8dp" />
<com.google.android.material.slider.Slider
android:id="@+id/sliderDehaze"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="-1.0"
android:valueTo="1.0"
android:value="0.0"
app:thumbColor="#FF9800"
app:trackColorActive="#FF9800"
app:trackColorInactive="#333333" />
<!-- NÚT LƯU THÀNH PRESET MỚI -->
<Button
android:id="@+id/btnSaveCustomPreset"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="LƯU THÀNH PRESET MỚI"
android:textColor="#FFFFFF"
android:backgroundTint="#FF9800"
android:textStyle="bold" />
</LinearLayout>
```
---
## 💻 2. Kịch Bản Tương Tác & Đồng Bộ Thời Gian Thực (Kotlin)
### 🔹 2.1. Chuẩn bị biến lưu trữ tạm thời (Clone Object)
Khi người dùng bấm nút Edit nhỏ từ Item trong RecyclerView, chúng ta không chỉnh sửa trực tiếp lên file gốc mà nhân bản dữ liệu ra một biến tạm:
```kotlin
private var editingPreset: ColorPreset? = null
fun openAdvancedSliders(selectedPreset: ColorPreset) {
// Clone đối tượng thông qua cơ chế sao chép dữ liệu (Deep Copy hoặc JSON String conversion)
editingPreset = selectedPreset.copy(
basic = selectedPreset.basic.copy(),
advanced = selectedPreset.advanced.copy()
)
// Cập nhật giá trị hiện tại lên các thanh Slider trực quan
binding.sliderVibrance.value = editingPreset!!.basic.vibrance
binding.sliderClarity.value = editingPreset!!.advanced.clarity
binding.sliderDehaze.value = editingPreset!!.advanced.dehaze
// Hiển thị bảng kèm hoạt họa trượt lên mượt mà (Slide up animation)
binding.layoutAdvancedSliders.apply {
visibility = View.VISIBLE
translationY = this.height.toFloat()
animate().translationY(0f).setDuration(300).start()
}
}
```
### 🔹 2.2. Lắng nghe thay đổi của Sliders và cập nhật Camera (Real-time Pipeline)
1. **Bước 1: Người dùng kéo Slider:** Lắng nghe sự kiện.
Gán trình lắng nghe `addOnChangeListener` cho từng Slider (ví dụ: `sliderVibrance`). Khi người dùng di chuyển ngón tay, giá trị float (`value`) thay đổi liên tục.
2. **Bước 2: Cập nhật thông số vào Object tạm:** Cập nhật Biến số.
Cập nhật ngay giá trị mới vào biến tạm thời:
`editingPreset?.basic?.vibrance = value`.
3. **Bước 3: Đẩy thông số vào Khung hình Camera:** Render GPU.
Gọi hàm `applyLiveFilterToCamera(editingPreset)`. Hàm này sẽ chuyển đổi toàn bộ thông số của `editingPreset` thành một ma trận cấu hình hoặc nạp trực tiếp vào OpenGL Shader (hoặc GPUImage) đang liên kết với `PreviewView` của CameraX. Màn hình camera sẽ thay đổi sắc độ và cấu trúc ảnh ngay lập tức.
---
## 💾 3. Quy Trình Đổi Tên Và Lưu File Độc Lập
Khi bấm nút **btnSaveCustomPreset**, ứng dụng sẽ thực hiện các bước sau để tránh đè lên preset mặc định:
1. **Hiển thị Dialog nhập tên:** Mở một `AlertDialog` tối giản với một ô `EditText` để người dùng đặt tên cho bộ lọc của họ (Ví dụ mặc định gợi ý sẵn: `[Tên cũ] - Custom`).
2. **Sinh ID duy nhất:** Sử dụng `UUID.randomUUID().toString()` để tạo ID mới cho preset này. Đổi thuộc tính `themeCategory` thành `"User Custom"`.
3. **Lưu file JSON vật lý:** Sử dụng thư viện GSON để chuyển đổi Object `editingPreset` vừa tạo thành chuỗi JSON và ghi vào thư mục lưu trữ nội bộ của ứng dụng trên điện thoại:
```kotlin
val file = File(context.filesDir, "preset_${newId}.json")
file.writeText(gson.toJson(editingPreset))
```
4. **Cập nhật lại thanh trượt ngoài màn hình chính:** Đóng bảng slider nâng cao, nạp lại danh sách preset từ thư mục chứa file để item mới vừa tạo xuất hiện ngay lập tức trên thanh Timeline ngang.
---
## 📅 4. Các Đầu Việc Cần Làm Tiếp Theo (Checklist)
* [ ] **Cài đặt thư viện đồ họa thời gian thực:** Cấu hình thư viện xử lý ảnh (như `GPUImage` cho Android) chạy song song với `PreviewView` của CameraX để nhận các lệnh cập nhật thông số liên tục từ Slider mà không bị giật lag khung hình.
* [ ] **Thiết kế hoạt họa đóng/mở:** Viết các tệp Animation (`slide_up.xml`, `slide_down.xml`) để tạo hiệu ứng chuyển cảnh mượt mà cho bảng trượt Overlay.
* [ ] **Kiểm tra giới hạn kéo:** Thiết lập tính năng hiển thị số thực tế (ví dụ: `+0.25`, `-0.10`) ngay cạnh tên Slider khi người dùng đang kéo để họ nhận biết được mức độ tăng giảm chính xác.
@@ -14,6 +14,8 @@ import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager
import androidx.constraintlayout.widget.ConstraintLayout
import com.photobooth.app.databinding.ActivityMainBinding
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -71,8 +73,8 @@ class MainActivity : AppCompatActivity() {
FrameItem("photobooth_4strip", "Photobooth 4-Strip", "frames/photobooth_4strip.png")
)
// Initialize Color Presets matching 6_PRESETS_COLOR.md
colorPresets = listOf(
// Initialize default Color Presets
val defaultPresets = listOf(
ColorPreset(
id = "DEFAULT",
name = "Mặc định",
@@ -114,21 +116,69 @@ class MainActivity : AppCompatActivity() {
grain = FilmGrain(0.15f, 0.12f)
)
)
// Load custom user saved presets from filesDir
colorPresets = defaultPresets + loadCustomPresets()
}
private fun loadCustomPresets(): List<ColorPreset> {
val customList = mutableListOf<ColorPreset>()
val files = filesDir.listFiles { _, name -> name.endsWith(".json") }
files?.forEach { file ->
try {
val content = file.readText()
val id = extractJsonField(content, "id")
val name = extractJsonField(content, "name")
val category = extractJsonField(content, "theme_category")
val brightness = extractJsonDoubleField(content, "brightness").toFloat()
val contrast = extractJsonDoubleField(content, "contrast").toFloat()
val saturation = extractJsonDoubleField(content, "saturation").toFloat()
val vibrance = extractJsonDoubleField(content, "vibrance").toFloat()
val preset = ColorPreset(
id = id,
name = name,
themeCategory = category,
isEditable = true,
basic = BasicAdjustments(brightness, contrast, saturation, vibrance, 0f, 0f),
advanced = AdvancedEffects(0f, 0f, 0f),
toneCurve = ToneCurveEmulation(0f, 0f, 0f, 0f),
grain = FilmGrain(0f, 0f)
)
customList.add(preset)
} catch (e: Exception) {
// Skip invalid JSON presets
}
}
return customList
}
private fun extractJsonField(json: String, field: String): String {
val pattern = "\"$field\"\\s*:\\s*\"([^\"]*)\"".toRegex()
return pattern.find(json)?.groupValues?.get(1) ?: ""
}
private fun extractJsonDoubleField(json: String, field: String): Double {
val pattern = "\"$field\"\\s*:\\s*(-?\\d+\\.?\\d*)".toRegex()
return pattern.find(json)?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0
}
private fun setupRecyclerViews() {
// Setup Shared Timeline RecyclerView
binding.rvSharedTimeline.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
binding.rvHorizontalTimeline.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
frameItemAdapter = FrameItemAdapter(allFrames) { frame ->
setFrameOverlay(frame.imageFileName)
updateFrameModeIcon(frame)
}
presetAdapter = PresetAdapter(colorPresets) { preset ->
presetAdapter = PresetAdapter(colorPresets, { preset ->
applyPresetFilter(preset)
updatePresetModeIcon(preset)
}
}, { preset ->
showEditPresetDialog(preset)
})
}
private fun setFrameOverlay(fileName: String) {
@@ -158,7 +208,14 @@ class MainActivity : AppCompatActivity() {
"instax_faded_warm_01" -> android.graphics.Color.argb(55, 255, 152, 0)
"instax_bw_cool" -> android.graphics.Color.argb(80, 128, 128, 128)
"instax_cool_summer" -> android.graphics.Color.argb(45, 0, 188, 212)
else -> android.graphics.Color.TRANSPARENT
else -> {
// Map user configured RGB overlay
val alpha = ((preset.basic.vibrance + 1.0f) * 40 + 20).toInt().coerceIn(10, 100)
val r = ((preset.basic.brightness + 1.0f) * 127).toInt().coerceIn(0, 255)
val g = ((preset.basic.contrast + 1.0f) * 100 + 20).toInt().coerceIn(0, 255)
val b = ((preset.basic.saturation + 1.0f) * 80 + 10).toInt().coerceIn(0, 255)
android.graphics.Color.argb(alpha, r, g, b)
}
}
binding.imgFilterOverlay.setBackgroundColor(filterColor)
}
@@ -166,21 +223,21 @@ class MainActivity : AppCompatActivity() {
private fun updateFrameModeIcon(frame: FrameItem) {
if (frame.id == "DEFAULT" || frame.imageFileName.isEmpty()) {
binding.btnModeFrame.setImageResource(R.drawable.ic_default_frame_thumbnail)
binding.btnMainFrame.setImageResource(R.drawable.ic_default_frame_thumbnail)
} else {
try {
val inputStream = assets.open(frame.imageFileName)
val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
binding.btnModeFrame.setImageBitmap(bitmap)
binding.btnMainFrame.setImageBitmap(bitmap)
} catch (e: Exception) {
binding.btnModeFrame.setImageResource(R.drawable.ic_default_frame_thumbnail)
binding.btnMainFrame.setImageResource(R.drawable.ic_default_frame_thumbnail)
}
}
}
private fun updatePresetModeIcon(preset: ColorPreset) {
if (preset.id == "DEFAULT") {
binding.btnModePreset.setImageResource(R.drawable.ic_default_preset_thumbnail)
binding.btnMainPreset.setImageResource(R.drawable.ic_default_preset_thumbnail)
} else {
val colorHex = when (preset.id) {
"instax_faded_warm_01" -> "#FFF57C00"
@@ -192,17 +249,17 @@ class MainActivity : AppCompatActivity() {
shape = android.graphics.drawable.GradientDrawable.OVAL
setColor(android.graphics.Color.parseColor(colorHex))
}
binding.btnModePreset.setImageDrawable(circleDrawable)
binding.btnMainPreset.setImageDrawable(circleDrawable)
}
}
private fun setupCameraControls() {
// Mode Selectors
binding.btnModeFrame.setOnClickListener {
binding.btnMainFrame.setOnClickListener {
toggleFrameMode()
}
binding.btnModePreset.setOnClickListener {
binding.btnMainPreset.setOnClickListener {
togglePresetMode()
}
@@ -249,40 +306,249 @@ class MainActivity : AppCompatActivity() {
private fun toggleFrameMode() {
if (isCountingDown) return
val wrapper = binding.buttonsWrapper
TransitionManager.beginDelayedTransition(wrapper)
if (!isFrameModeOpen) {
isFrameModeOpen = true
isPresetModeOpen = false
// Show Shared timeline under frame mode, hide preset button
binding.btnModePreset.visibility = android.view.View.GONE
binding.rvSharedTimeline.visibility = android.view.View.VISIBLE
// Hide preset button
binding.btnMainPreset.visibility = android.view.View.GONE
binding.rvSharedTimeline.adapter = frameItemAdapter
// Translate Frame button to left edge anchor
val params = binding.btnMainFrame.layoutParams as ConstraintLayout.LayoutParams
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.endToStart = ConstraintLayout.LayoutParams.UNSET
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
binding.btnMainFrame.layoutParams = params
// Show horizontal timeline
binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE
binding.rvHorizontalTimeline.adapter = frameItemAdapter
} else {
isFrameModeOpen = false
// Collapse Frame UI, restore Preset button
binding.btnModePreset.visibility = android.view.View.VISIBLE
binding.rvSharedTimeline.visibility = android.view.View.GONE
// Reset Frame button layout params to center chain
val params = binding.btnMainFrame.layoutParams as ConstraintLayout.LayoutParams
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.endToStart = binding.btnMainPreset.id
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
binding.btnMainFrame.layoutParams = params
// Show preset button
binding.btnMainPreset.visibility = android.view.View.VISIBLE
// Hide timeline
binding.rvHorizontalTimeline.visibility = android.view.View.GONE
}
}
private fun togglePresetMode() {
if (isCountingDown) return
val wrapper = binding.buttonsWrapper
TransitionManager.beginDelayedTransition(wrapper)
if (!isPresetModeOpen) {
isPresetModeOpen = true
isFrameModeOpen = false
// Show Shared timeline with Presets, hide Frame button
binding.btnModeFrame.visibility = android.view.View.GONE
binding.rvSharedTimeline.visibility = android.view.View.VISIBLE
// Hide frame button
binding.btnMainFrame.visibility = android.view.View.GONE
// Translate Preset button to left edge anchor (occupying Frame button start spot)
val params = binding.btnMainPreset.layoutParams as ConstraintLayout.LayoutParams
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.startToEnd = ConstraintLayout.LayoutParams.UNSET
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
binding.btnMainPreset.layoutParams = params
// Show horizontal timeline
binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE
presetAdapter.resetSelection()
binding.rvSharedTimeline.adapter = presetAdapter
binding.rvHorizontalTimeline.adapter = presetAdapter
} else {
isPresetModeOpen = false
// Collapse Preset UI, restore Frame button
binding.btnModeFrame.visibility = android.view.View.VISIBLE
binding.rvSharedTimeline.visibility = android.view.View.GONE
// Reset Preset button layout params back to center chain
val params = binding.btnMainPreset.layoutParams as ConstraintLayout.LayoutParams
params.startToStart = ConstraintLayout.LayoutParams.UNSET
params.startToEnd = binding.btnMainFrame.id
params.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
binding.btnMainPreset.layoutParams = params
// Show frame button
binding.btnMainFrame.visibility = android.view.View.VISIBLE
// Hide timeline
binding.rvHorizontalTimeline.visibility = android.view.View.GONE
}
}
private fun showEditPresetDialog(preset: ColorPreset) {
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_preset, null)
val txtBrightness: android.widget.TextView = dialogView.findViewById(R.id.txtLabelBrightness)
val seekBrightness: android.widget.SeekBar = dialogView.findViewById(R.id.seekBrightness)
val txtContrast: android.widget.TextView = dialogView.findViewById(R.id.txtLabelContrast)
val seekContrast: android.widget.SeekBar = dialogView.findViewById(R.id.seekContrast)
val txtSaturation: android.widget.TextView = dialogView.findViewById(R.id.txtLabelSaturation)
val seekSaturation: android.widget.SeekBar = dialogView.findViewById(R.id.seekSaturation)
val txtVibrance: android.widget.TextView = dialogView.findViewById(R.id.txtLabelVibrance)
val seekVibrance: android.widget.SeekBar = dialogView.findViewById(R.id.seekVibrance)
// Set current values mapping (-1.0f..1.0f -> 0..200)
seekBrightness.progress = ((preset.basic.brightness + 1.0f) * 100).toInt()
seekContrast.progress = ((preset.basic.contrast + 1.0f) * 100).toInt()
seekSaturation.progress = ((preset.basic.saturation + 1.0f) * 100).toInt()
seekVibrance.progress = ((preset.basic.vibrance + 1.0f) * 100).toInt()
txtBrightness.text = "Độ sáng (Brightness): ${String.format("%.2f", preset.basic.brightness)}"
txtContrast.text = "Độ tương phản (Contrast): ${String.format("%.2f", preset.basic.contrast)}"
txtSaturation.text = "Độ bão hòa (Saturation): ${String.format("%.2f", preset.basic.saturation)}"
txtVibrance.text = "Độ sống động (Vibrance): ${String.format("%.2f", preset.basic.vibrance)}"
// Clone current preset to modify values temporarily
val tempPreset = ColorPreset(
id = preset.id,
name = preset.name,
themeCategory = preset.themeCategory,
isEditable = preset.isEditable,
basic = BasicAdjustments(preset.basic.brightness, preset.basic.contrast, preset.basic.saturation, preset.basic.vibrance, preset.basic.temperature, preset.basic.tint),
advanced = AdvancedEffects(preset.advanced.clarity, preset.advanced.dehaze, preset.advanced.vignetteAmount),
toneCurve = ToneCurveEmulation(preset.toneCurve.fadedBlackLevel, preset.toneCurve.shadowsTintR, preset.toneCurve.shadowsTintG, preset.toneCurve.shadowsTintB),
grain = FilmGrain(preset.grain.grainAmount, preset.grain.grainSize)
)
val seekBarListener = object : android.widget.SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: android.widget.SeekBar?, progress: Int, fromUser: Boolean) {
val valFloat = (progress - 100) / 100f
when (seekBar?.id) {
R.id.seekBrightness -> {
tempPreset.basic.brightness = valFloat
txtBrightness.text = "Độ sáng (Brightness): ${String.format("%.2f", valFloat)}"
}
R.id.seekContrast -> {
tempPreset.basic.contrast = valFloat
txtContrast.text = "Độ tương phản (Contrast): ${String.format("%.2f", valFloat)}"
}
R.id.seekSaturation -> {
tempPreset.basic.saturation = valFloat
txtSaturation.text = "Độ bão hòa (Saturation): ${String.format("%.2f", valFloat)}"
}
R.id.seekVibrance -> {
tempPreset.basic.vibrance = valFloat
txtVibrance.text = "Độ sống động (Vibrance): ${String.format("%.2f", valFloat)}"
}
}
// Live preview camera filter overlay while sliding seekbar!
applyPresetFilter(tempPreset)
}
override fun onStartTrackingTouch(seekBar: android.widget.SeekBar?) {}
override fun onStopTrackingTouch(seekBar: android.widget.SeekBar?) {}
}
seekBrightness.setOnSeekBarChangeListener(seekBarListener)
seekContrast.setOnSeekBarChangeListener(seekBarListener)
seekSaturation.setOnSeekBarChangeListener(seekBarListener)
seekVibrance.setOnSeekBarChangeListener(seekBarListener)
androidx.appcompat.app.AlertDialog.Builder(this)
.setView(dialogView)
.setPositiveButton("Lưu") { _, _ ->
showSavePresetDialog(tempPreset)
}
.setNegativeButton("Hủy") { dialog, _ ->
applyPresetFilter(preset) // Restore
dialog.dismiss()
}
.show()
}
private fun showSavePresetDialog(tempPreset: ColorPreset) {
val input = android.widget.EditText(this).apply {
hint = "Nhập tên Preset mới"
}
val container = android.widget.FrameLayout(this).apply {
setPadding(50, 20, 50, 20)
addView(input)
}
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("Lưu Preset cá nhân")
.setView(container)
.setPositiveButton("Lưu") { _, _ ->
val name = input.text.toString().trim()
if (name.isNotEmpty()) {
saveCustomPreset(tempPreset, name)
} else {
Toast.makeText(this, "Tên không được để trống", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton("Hủy", null)
.show()
}
private fun saveCustomPreset(tempPreset: ColorPreset, newName: String) {
val customId = "custom_" + System.currentTimeMillis()
val customPreset = ColorPreset(
id = customId,
name = newName,
themeCategory = "Custom",
isEditable = true,
basic = BasicAdjustments(tempPreset.basic.brightness, tempPreset.basic.contrast, tempPreset.basic.saturation, tempPreset.basic.vibrance, 0f, 0f),
advanced = AdvancedEffects(0f, 0f, 0f),
toneCurve = ToneCurveEmulation(0f, 0f, 0f, 0f),
grain = FilmGrain(0f, 0f)
)
// Serialize to JSON format manually
val jsonString = """
{
"id": "$customId",
"name": "$newName",
"theme_category": "Custom",
"is_editable": true,
"basic_adjustments": {
"brightness": ${customPreset.basic.brightness},
"contrast": ${customPreset.basic.contrast},
"saturation": ${customPreset.basic.saturation},
"vibrance": ${customPreset.basic.vibrance},
"temperature": 0.0,
"tint": 0.0
},
"advanced_effects": {
"clarity": 0.0,
"dehaze": 0.0,
"vignette_amount": 0.0
},
"tone_curve_emulation": {
"faded_black_level": 0.0,
"shadows_tint_r": 0.0,
"shadows_tint_g": 0.0,
"shadows_tint_b": 0.0
},
"film_grain": {
"grain_amount": 0.0,
"grain_size": 0.0
}
}
""".trimIndent()
try {
val file = java.io.File(filesDir, "$customId.json")
file.writeText(jsonString)
// Dynamic UI update
colorPresets = colorPresets + customPreset
presetAdapter.updatePresets(colorPresets)
Toast.makeText(this, "Đã lưu preset: $newName", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "Không thể lưu Preset file", Toast.LENGTH_SHORT).show()
}
}
@@ -331,12 +597,8 @@ class MainActivity : AppCompatActivity() {
}
// Reset Selector UI states
isFrameModeOpen = false
isPresetModeOpen = false
binding.btnModeFrame.visibility = android.view.View.VISIBLE
binding.btnModePreset.visibility = android.view.View.VISIBLE
binding.rvSharedTimeline.visibility = android.view.View.GONE
if (isFrameModeOpen) toggleFrameMode()
if (isPresetModeOpen) togglePresetMode()
setFrameOverlay("")
applyPresetFilter(colorPresets[0])
updateFrameModeIcon(FrameItem("DEFAULT", "", ""))
@@ -5,6 +5,7 @@ import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
@@ -12,15 +13,18 @@ import com.google.android.material.card.MaterialCardView
class PresetAdapter(
private var presets: List<ColorPreset>,
private val onPresetSelected: (ColorPreset) -> Unit
private val onPresetSelected: (ColorPreset) -> Unit,
private val onEditClicked: (ColorPreset) -> Unit
) : RecyclerView.Adapter<PresetAdapter.ViewHolder>() {
private var selectedPosition = 0 // Mặc định là Mặc định (0)
private var selectedPosition = 0
private var editingPosition = -1
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val cardPreset: MaterialCardView = view.findViewById(R.id.cardPreset)
val viewPresetColor: View = view.findViewById(R.id.viewPresetColor)
val txtPresetName: TextView = view.findViewById(R.id.txtPresetName)
val btnSmallEdit: ImageButton = view.findViewById(R.id.btnSmallEdit)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@@ -39,9 +43,9 @@ class PresetAdapter(
holder.txtPresetName.text = holder.itemView.context.getString(R.string.default_frame)
} else {
val colorHex = when (preset.id) {
"instax_faded_warm_01" -> "#FFF57C00" // Orange Warm
"instax_bw_cool" -> "#FF808080" // Gray B&W
"instax_cool_summer" -> "#FF00BCD4" // Cyan Cool
"instax_faded_warm_01" -> "#FFF57C00"
"instax_bw_cool" -> "#FF808080"
"instax_cool_summer" -> "#FF00BCD4"
else -> "#FF9E9E9E"
}
val circleDrawable = GradientDrawable().apply {
@@ -51,8 +55,15 @@ class PresetAdapter(
holder.viewPresetColor.background = circleDrawable
}
val strokeWidthPx = (3 * holder.itemView.context.resources.displayMetrics.density).toInt()
// Show edit button on long press
if (position == editingPosition && preset.isEditable) {
holder.btnSmallEdit.visibility = View.VISIBLE
} else {
holder.btnSmallEdit.visibility = View.GONE
}
// Highlight selection
val strokeWidthPx = (3 * holder.itemView.context.resources.displayMetrics.density).toInt()
if (position == selectedPosition) {
holder.cardPreset.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_primary)
holder.cardPreset.strokeWidth = strokeWidthPx
@@ -68,13 +79,36 @@ class PresetAdapter(
notifyItemChanged(selectedPosition)
onPresetSelected(preset)
}
holder.itemView.setOnLongClickListener {
if (preset.isEditable) {
val prevEditing = editingPosition
editingPosition = if (editingPosition == position) -1 else position
notifyItemChanged(prevEditing)
if (editingPosition != -1) {
notifyItemChanged(editingPosition)
}
holder.itemView.performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS)
}
true
}
holder.btnSmallEdit.setOnClickListener {
onEditClicked(preset)
}
}
override fun getItemCount(): Int = presets.size
fun updatePresets(newPresets: List<ColorPreset>) {
this.presets = newPresets
notifyDataSetChanged()
}
fun resetSelection() {
val previousSelected = selectedPosition
selectedPosition = 0
editingPosition = -1
notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition)
}
@@ -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="@color/theme_white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>
@@ -61,7 +61,7 @@
android:layout_height="0dp"
app:layout_constraintDimensionRatio="3:4"
app:layout_constraintTop_toBottomOf="@id/topBar"
app:layout_constraintBottom_toTopOf="@id/timelineContainer">
app:layout_constraintBottom_toTopOf="@id/panelSelectionContainer">
<!-- Lớp đáy: Kính ngắm CameraX -->
<androidx.camera.view.PreviewView
@@ -103,62 +103,64 @@
android:text="3" />
</FrameLayout>
<!-- ================= 3. TIMELINE DANH SÁCH FRAMES / PRESETS DÙNG CHUNG ================= -->
<LinearLayout
android:id="@+id/timelineContainer"
<!-- ================= 3. KHU VỰC ĐIỀU KHIỂN DÒNG (CHỒNG LỚP CUỘN ĐÈ) ================= -->
<FrameLayout
android:id="@+id/panelSelectionContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_height="70dp"
android:background="#000000"
app:layout_constraintTop_toBottomOf="@id/cameraContainer"
app:layout_constraintBottom_toTopOf="@id/toggleModeContainer">
app:layout_constraintBottom_toTopOf="@id/zoomControls">
<!-- LỚP ĐÁY: Danh sách cuộn ngang dùng chung cho cả Frame và Preset -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSharedTimeline"
android:id="@+id/rvHorizontalTimeline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:padding="6dp"
android:background="@color/theme_sub_bar_bg"
android:clipToPadding="false"
android:paddingStart="80dp"
android:paddingEnd="16dp"
android:visibility="gone" />
</LinearLayout>
<!-- ================= 4. TOGGLE CHẾ ĐỘ FRAME / PRESETS ================= -->
<LinearLayout
android:id="@+id/toggleModeContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginVertical="8dp"
app:layout_constraintTop_toBottomOf="@id/timelineContainer"
app:layout_constraintBottom_toTopOf="@id/zoomControls"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<!-- LỚP TRÊN: Chứa 2 nút chức năng chính (Gốc ở giữa, động chuyển sang trái) -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/buttonsWrapper"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/btnModeFrame"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginHorizontal="12dp"
android:padding="4dp"
android:src="@drawable/ic_default_frame_thumbnail"
android:background="@color/theme_transparent"
android:scaleType="fitCenter"
android:contentDescription="Frame Selector" />
<!-- Nút Frame -->
<ImageView
android:id="@+id/btnMainFrame"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@drawable/ic_default_frame_thumbnail"
android:background="@android:color/transparent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnMainPreset"
app:layout_constraintHorizontal_chainStyle="packed"
android:layout_marginEnd="16dp"
android:contentDescription="Frame Selector" />
<ImageView
android:id="@+id/btnModePreset"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginHorizontal="12dp"
android:padding="4dp"
android:src="@drawable/ic_default_preset_thumbnail"
android:background="@color/theme_transparent"
android:scaleType="fitCenter"
android:contentDescription="Preset Color Selector" />
</LinearLayout>
<!-- Nút Presets Màu -->
<ImageView
android:id="@+id/btnMainPreset"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@drawable/ic_default_preset_thumbnail"
android:background="@android:color/transparent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/btnMainFrame"
app:layout_constraintEnd_toEndOf="parent"
android:contentDescription="Preset Color Selector" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
<!-- ================= 5. CỤM NÚT ĐIỀU KHIỂN ZOOM (TEXTVIEW) ================= -->
<!-- ================= 4. CỤM NÚT ĐIỀU KHIỂN ZOOM (TEXTVIEW) ================= -->
<LinearLayout
android:id="@+id/zoomControls"
android:layout_width="wrap_content"
@@ -166,7 +168,7 @@
android:orientation="horizontal"
android:background="@color/theme_transparent"
android:padding="4dp"
app:layout_constraintTop_toBottomOf="@id/toggleModeContainer"
app:layout_constraintTop_toBottomOf="@id/panelSelectionContainer"
app:layout_constraintBottom_toTopOf="@id/controlPanel"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
@@ -205,7 +207,7 @@
android:padding="6dp" />
</LinearLayout>
<!-- ================= 6. CONTROL PANEL CHỤP ẢNH (PLAY + SHUTTER) ================= -->
<!-- ================= 5. CONTROL PANEL CHỤP ẢNH (PLAY + SHUTTER) ================= -->
<RelativeLayout
android:id="@+id/controlPanel"
android:layout_width="match_parent"
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/theme_bg_dark"
android:padding="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/txtDialogTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Chỉnh sửa Preset màu"
android:textColor="@color/theme_white"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="20dp"/>
<!-- Brightness Slider -->
<TextView
android:id="@+id/txtLabelBrightness"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/theme_white"
android:textSize="12sp"
android:text="Độ sáng (Brightness): 0.0" />
<SeekBar
android:id="@+id/seekBrightness"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100"
android:layout_marginTop="6dp"
android:layout_marginBottom="16dp" />
<!-- Contrast Slider -->
<TextView
android:id="@+id/txtLabelContrast"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/theme_white"
android:textSize="12sp"
android:text="Độ tương phản (Contrast): 0.0" />
<SeekBar
android:id="@+id/seekContrast"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100"
android:layout_marginTop="6dp"
android:layout_marginBottom="16dp" />
<!-- Saturation Slider -->
<TextView
android:id="@+id/txtLabelSaturation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/theme_white"
android:textSize="12sp"
android:text="Độ bão hòa (Saturation): 0.0" />
<SeekBar
android:id="@+id/seekSaturation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100"
android:layout_marginTop="6dp"
android:layout_marginBottom="16dp" />
<!-- Vibrance Slider -->
<TextView
android:id="@+id/txtLabelVibrance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/theme_white"
android:textSize="12sp"
android:text="Độ sống động (Vibrance): 0.0" />
<SeekBar
android:id="@+id/seekVibrance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100"
android:layout_marginTop="6dp"
android:layout_marginBottom="16dp" />
</LinearLayout>
</ScrollView>
@@ -1,39 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cardPreset"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_margin="4dp"
app:cardCornerRadius="6dp"
app:cardElevation="1dp"
app:strokeWidth="0dp"
app:cardBackgroundColor="@color/theme_item_card_bg">
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="4dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardPreset"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_margin="6dp"
app:cardCornerRadius="6dp"
app:cardElevation="1dp"
app:strokeWidth="0dp"
app:cardBackgroundColor="@color/theme_item_card_bg">
<!-- Vòng tròn màu xem trước bộ lọc -->
<View
android:id="@+id/viewPresetColor"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/ic_default_preset_thumbnail" />
<TextView
android:id="@+id/txtPresetName"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:textColor="@color/theme_white"
android:textSize="10sp"
android:layout_marginTop="4dp"
android:singleLine="true"
android:ellipsize="end"
android:text="Preset" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
android:padding="4dp">
<!-- Vòng tròn màu xem trước bộ lọc -->
<View
android:id="@+id/viewPresetColor"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/ic_default_preset_thumbnail" />
<TextView
android:id="@+id/txtPresetName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/theme_white"
android:textSize="10sp"
android:layout_marginTop="4dp"
android:singleLine="true"
android:ellipsize="end"
android:text="Preset" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Nút edit nhỏ ở góc trên bên phải, hiển thị đè đục lên card -->
<ImageButton
android:id="@+id/btnSmallEdit"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="top|end"
android:background="@android:color/transparent"
android:src="@drawable/ic_edit_small"
android:visibility="gone"
app:tint="@color/theme_primary"
android:contentDescription="Edit Preset" />
</FrameLayout>