diff --git a/7_UX_BUTTONS.md b/7_UX_BUTTONS.md
new file mode 100644
index 0000000..bdd9b6c
--- /dev/null
+++ b/7_UX_BUTTONS.md
@@ -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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 💻 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ẻ.
+
diff --git a/8_SLIDER_TABLE.md b/8_SLIDER_TABLE.md
new file mode 100644
index 0000000..a77617f
--- /dev/null
+++ b/8_SLIDER_TABLE.md
@@ -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
+
+
+ android:padding="20dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:visibility="gone"
+ app:layout_constraintBottom_toTopOf="@id/panelSelectionContainer">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 💻 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.
+
diff --git a/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt b/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt
index 65c90a4..1ad59a0 100644
--- a/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt
+++ b/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt
@@ -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 {
+ val customList = mutableListOf()
+ 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", "", ""))
diff --git a/android-project/app/src/main/java/com/photobooth/app/PresetAdapter.kt b/android-project/app/src/main/java/com/photobooth/app/PresetAdapter.kt
index a957d87..345c1ad 100644
--- a/android-project/app/src/main/java/com/photobooth/app/PresetAdapter.kt
+++ b/android-project/app/src/main/java/com/photobooth/app/PresetAdapter.kt
@@ -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,
- private val onPresetSelected: (ColorPreset) -> Unit
+ private val onPresetSelected: (ColorPreset) -> Unit,
+ private val onEditClicked: (ColorPreset) -> Unit
) : RecyclerView.Adapter() {
- 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) {
+ this.presets = newPresets
+ notifyDataSetChanged()
+ }
+
fun resetSelection() {
val previousSelected = selectedPosition
selectedPosition = 0
+ editingPosition = -1
notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition)
}
diff --git a/android-project/app/src/main/res/drawable/ic_edit_small.xml b/android-project/app/src/main/res/drawable/ic_edit_small.xml
new file mode 100644
index 0000000..97d4d10
--- /dev/null
+++ b/android-project/app/src/main/res/drawable/ic_edit_small.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android-project/app/src/main/res/layout/activity_main.xml b/android-project/app/src/main/res/layout/activity_main.xml
index 0e496f4..49d0be9 100644
--- a/android-project/app/src/main/res/layout/activity_main.xml
+++ b/android-project/app/src/main/res/layout/activity_main.xml
@@ -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">
-
-
+
+ app:layout_constraintBottom_toTopOf="@id/zoomControls">
+
-
-
-
+
+
-
+
+
-
-
+
+
+
+
+
-
+
@@ -205,7 +207,7 @@
android:padding="6dp" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-project/app/src/main/res/layout/item_preset.xml b/android-project/app/src/main/res/layout/item_preset.xml
index 0267e9f..d036104 100644
--- a/android-project/app/src/main/res/layout/item_preset.xml
+++ b/android-project/app/src/main/res/layout/item_preset.xml
@@ -1,39 +1,56 @@
-
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
-
+
-
-
-
-
-
-
+ android:padding="4dp">
+
+
+
+
+
+
+
+
+
+
+