Compare commits

...

10 Commits

Author SHA1 Message Date
3dtours 4e3c6f35c7 fix: chỉnh sửa button cho phần chỉnh presets 2026-07-05 21:55:40 +07:00
3dtours b5d7311447 Fix: Enable real-time ARGB color overlay preview updates by bypassing hardcoded colors in edit mode 2026-07-05 18:20:46 +07:00
3dtours bcbb853558 Feat: Complete 14 sliders integration, remove pen button and implement custom 3-second long press (10_FIX_SLIDER.md) 2026-07-05 18:16:39 +07:00
3dtours 1a74887fe0 Feat: Implement floating advanced sliders inside camera view finder with scale animation (9_FEAT_UI.md) 2026-07-05 18:10:42 +07:00
3dtours 2308fe6fa9 Feat: Implement advanced sliders real-time color overlay panel (8_SLIDER_TABLE.md) 2026-07-05 17:55:32 +07:00
3dtours be997a0c72 Fix: Align horizontal timeline items and anchor button vertically to parent center 2026-07-05 17:46:37 +07:00
3dtours fad1c0a264 Fix: Resolve timeline overlapping by increasing container height to 96dp and reducing card sizes to 68dp 2026-07-05 17:43:49 +07:00
3dtours 424ca4b419 Implement 7.1_FIX_UX_BUTTON: Restructured timeline selection & bottom control panels, integrated zoom group inside bottom ControlPanel, updated Play button to Gallery Image icon and customized orange circle shutter button background 2026-07-05 17:34:37 +07:00
3dtours 4a33140c67 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 2026-07-05 17:27:10 +07:00
3dtours edd4c2946d Implement 5_UI_ANDROID & 6_PRESETS_COLOR: Restructure to minimal UX/UI, merged frame/preset timeline, integrated ColorPreset models and real-time color filter overlay 2026-07-05 17:10:03 +07:00
54 changed files with 3629 additions and 191 deletions
+221
View File
@@ -0,0 +1,221 @@
Dưới đây là kế hoạch hoàn thiện toàn bộ danh sách các thanh kéo (Sliders) trong bảng Floating Card mờ cho tệp cấu hình JSON đầy đủ của bạn. Chúng ta sẽ đưa tất cả 14 thông số chia theo đúng 4 nhóm cấu trúc của tệp cấu hình vào trong giao diện cuộn dọc của `ScrollView`.
---
## 📐 1. Cấu Trúc Đầy Đủ Cho Layout XML (`activity_main.xml`)
Thay thế phần nội dung bên trong `LinearLayout` của bảng Floating Card (nằm dưới tiêu đề) để bổ sung đầy đủ tất cả các thanh kéo, phân chia theo từng nhóm trực quan giúp người dùng dễ thao tác.
```xml
<!-- Đặt bên trong LinearLayout của bảng Floating Card mờ (Lớp 3 của cameraContainer) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- ================= NHÓM 1: CƠ BẢN (BASIC ADJUSTMENTS) ================= -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="ĐIỀU CHỈNH CƠ BẢN"
android:textColor="#FF9800"
android:textStyle="bold"
android:textSize="11sp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"/>
<!-- Brightness -->
<TextView android:id="@+id/tvLabelBrightness" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Brightness (Độ sáng): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderBrightness" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="-1.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Contrast -->
<TextView android:id="@+id/tvLabelContrast" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Contrast (Tương phản): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderContrast" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="-1.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Saturation -->
<TextView android:id="@+id/tvLabelSaturation" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Saturation (Bão hòa màu): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderSaturation" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="-1.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Vibrance -->
<TextView android:id="@+id/tvLabelVibrance" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Vibrance (Bão hòa thông minh): 0.00"/>
<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" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Temperature -->
<TextView android:id="@+id/tvLabelTemperature" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Temperature (Nhiệt màu): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderTemperature" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="-1.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Tint -->
<TextView android:id="@+id/tvLabelTint" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Tint (Sắc độ hồng/xanh): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderTint" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="-1.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- ================= NHÓM 2: NÂNG CAO (ADVANCED EFFECTS) ================= -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="HIỆU ỨNG NÂNG CAO"
android:textColor="#FF9800"
android:textStyle="bold"
android:textSize="11sp"
android:layout_marginTop="12dp"
android:layout_marginBottom="4dp"/>
<!-- Clarity -->
<TextView android:id="@+id/tvLabelClarity" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Clarity (Độ rõ nét vùng trung tính): 0.00"/>
<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" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Dehaze -->
<TextView android:id="@+id/tvLabelDehaze" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Dehaze (Độ trong suốt / Sương mù): 0.00"/>
<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" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Vignette Amount -->
<TextView android:id="@+id/tvLabelVignetteAmount" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Vignette Amount (Bo tối góc): 0.00"/>
<!-- Hiệu ứng bo tối góc đi từ 0.0 (Không bo) đến 1.0 (Bo đen tối đa) -->
<com.google.android.material.slider.Slider android:id="@+id/sliderVignetteAmount" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="0.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- ================= NHÓM 3: GIẢ LẬP ĐƯỜNG CONG MÀU (TONE CURVE EMULATION) ================= -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="GIẢ LẬP TONE CURVE &amp; MÀU VÙNG TỐI"
android:textColor="#FF9800"
android:textStyle="bold"
android:textSize="11sp"
android:layout_marginTop="12dp"
android:layout_marginBottom="4dp"/>
<!-- Faded Black Level -->
<TextView android:id="@+id/tvLabelFadedBlack" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Faded Black Level (Độ mờ vùng đen): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderFadedBlack" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="0.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Shadows Tint R -->
<TextView android:id="@+id/tvLabelShadowsTintR" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Shadows Tint R (Sắc Đỏ vùng tối): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderShadowsTintR" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="0.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Shadows Tint G -->
<TextView android:id="@+id/tvLabelShadowsTintG" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Shadows Tint G (Sắc Lục vùng tối): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderShadowsTintG" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="0.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Shadows Tint B -->
<TextView android:id="@+id/tvLabelShadowsTintB" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Shadows Tint B (Sắc Lam vùng tối): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderShadowsTintB" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="0.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- ================= NHÓM 4: HẠT NHIỄU PHIM (FILM GRAIN) ================= -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="HẠT NHIỄU PHIM"
android:textColor="#FF9800"
android:textStyle="bold"
android:textSize="11sp"
android:layout_marginTop="12dp"
android:layout_marginBottom="4dp"/>
<!-- Grain Amount -->
<TextView android:id="@+id/tvLabelGrainAmount" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Grain Amount (Mật độ hạt): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderGrainAmount" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="0.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
<!-- Grain Size -->
<TextView android:id="@+id/tvLabelGrainSize" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp" android:text="Grain Size (Kích thước hạt): 0.00"/>
<com.google.android.material.slider.Slider android:id="@+id/sliderGrainSize" android:layout_width="match_parent" android:layout_height="wrap_content" android:valueFrom="0.0" android:valueTo="1.0" app:thumbColor="#FF9800" app:trackColorActive="#FF9800"/>
</LinearLayout>
```
---
## 💻 2. Logic Khởi Tạo & Đồng Bộ Giá Trị (Kotlin trong `MainActivity.kt`)
Khi người dùng mở bảng chỉnh sửa cho preset, hàm `openAdvancedSliders` sẽ thực hiện gán các giá trị ban đầu từ tệp JSON lên các thanh Slider phần cứng đồng thời thiết lập lắng nghe cử chỉ:
```kotlin
fun openAdvancedSliders(selectedPreset: ColorPreset) {
editingPreset = selectedPreset.deepCopy() // Nhân bản dữ liệu để tránh ghi đè bản gốc
binding.tvEditPresetName.text = "Chỉnh sửa: ${editingPreset!!.name}"
// 1. Nạp dữ liệu Nhóm Basic
binding.sliderBrightness.value = editingPreset!!.basic.brightness
binding.sliderContrast.value = editingPreset!!.basic.contrast
binding.sliderSaturation.value = editingPreset!!.basic.saturation
binding.sliderVibrance.value = editingPreset!!.basic.vibrance
binding.sliderTemperature.value = editingPreset!!.basic.temperature
binding.sliderTint.value = editingPreset!!.basic.tint
// 2. Nạp dữ liệu Nhóm Advanced
binding.sliderClarity.value = editingPreset!!.advanced.clarity
binding.sliderDehaze.value = editingPreset!!.advanced.dehaze
binding.sliderVignetteAmount.value = editingPreset!!.advanced.vignetteAmount
// 3. Nạp dữ liệu Nhóm Tone Curve Emulation
binding.sliderFadedBlack.value = editingPreset!!.toneCurve.fadedBlackLevel
binding.sliderShadowsTintR.value = editingPreset!!.toneCurve.shadowsTintR
binding.sliderShadowsTintG.value = editingPreset!!.toneCurve.shadowsTintG
binding.sliderShadowsTintB.value = editingPreset!!.toneCurve.shadowsTintB
// 4. Nạp dữ liệu Nhóm Film Grain
binding.sliderGrainAmount.value = editingPreset!!.grain.grainAmount
binding.sliderGrainSize.value = editingPreset!!.grain.grainSize
// Gọi hàm thiết lập sự kiện lắng nghe chuyển động cho toàn bộ Sliders
setupSliderListeners()
// Hiển thị Card mờ floating lọt lòng màn hình camera như ảnh image_2be399.jpg
binding.cardAdvancedSliders.visibility = View.VISIBLE
}
```
---
## 🔄 3. Cơ Chế Cập Nhật Giá Trị Thời Gian Thực (Real-time Pipeline)
Viết một hàm tập trung gán sự kiện thay đổi (`addOnChangeListener`) cho tất cả các thanh kéo. Khi con trỏ dịch chuyển, chữ số nhãn text sẽ đổi ngay lập tức và đẩy cấu trúc Object mới sang GPU xử lý trực tiếp trên kính ngắm.
```kotlin
private fun setupSliderListeners() {
// Ví dụ gán cho Brightness
binding.sliderBrightness.addOnChangeListener { _, value, _ ->
binding.tvLabelBrightness.text = String.format("Brightness (Độ sáng): %.2f", value)
editingPreset?.basic?.brightness = value
applyLiveFilterToCamera(editingPreset) // Đẩy ma trận màu sang GPU render
}
// Ví dụ gán cho Vibrance
binding.sliderVibrance.addOnChangeListener { _, value, _ ->
binding.tvLabelVibrance.text = String.format("Vibrance (Bão hòa thông minh): %.2f", value)
editingPreset?.basic?.vibrance = value
applyLiveFilterToCamera(editingPreset)
}
// Ví dụ gán cho Clarity (Nhóm Advanced)
binding.sliderClarity.addOnChangeListener { _, value, _ ->
binding.tvLabelClarity.text = String.format("Clarity (Độ rõ nét vùng trung tính): %.2f", value)
editingPreset?.advanced?.clarity = value
applyLiveFilterToCamera(editingPreset)
}
// Ví dụ gán cho Faded Black Level (Nhóm Tone Curve)
binding.sliderFadedBlack.addOnChangeListener { _, value, _ ->
binding.tvLabelFadedBlack.text = String.format("Faded Black Level (Độ mờ vùng đen): %.2f", value)
editingPreset?.toneCurve?.fadedBlackLevel = value
applyLiveFilterToCamera(editingPreset)
}
// Ví dụ gán cho Grain Amount (Nhóm Film Grain)
binding.sliderGrainAmount.addOnChangeListener { _, value, _ ->
binding.tvLabelGrainAmount.text = String.format("Grain Amount (Mật độ hạt): %.2f", value)
editingPreset?.grain?.grainAmount = value
applyLiveFilterToCamera(editingPreset)
}
// Thực hiện viết gán tương tự cho toàn bộ các thanh Sliders còn lại trong danh sách...
}
```
Bảng cấu trúc 14 thông số này sẽ tạo ra khả năng tùy biến bộ lọc ảnh mạnh mẽ không thua kém gì các bộ công cụ chỉnh ảnh phim chuyên nghiệp như VSCO hay Lightroom Mobile.
+207
View File
@@ -0,0 +1,207 @@
# 🛠️ KẾ HOẠCH FIX BUG: CHUẨN HÓA LOGIC SLIDERS & FIX LỖI TRÀN KHUNG HIỂN THỊ
Mục tiêu:
1. Khắc phục triệt để lỗi gán nhầm thông số xử lý ảnh (ví dụ: kéo Brightness lại đổi Temperature).
2. Chuyển đổi dải giá trị từ số thập phân nhỏ sang thang đo nguyên trực quan từ `-100` đến `+100` để người dùng thấy rõ sự thay đổi.
3. Cô lập nội dung cuộn bên trong phạm vi bo góc của `MaterialCardView` để không bị tràn đè lên khung viền của bảng.
---
## 📐 1. Khắc Phục Lỗi Giao Diện Tràn Khung Biển (`activity_main.xml`)
**Nguyên nhân lỗi:** Do tệp cấu hình cũ đặt thuộc tính `padding` trực tiếp lên thẻ cha hoặc thẻ `LinearLayout` bên trong mà không giới hạn vùng hiển thị đồ họa của thẻ cuộn `ScrollView`. Khi vuốt, các phần tử chữ và thanh kéo sẽ trượt đè qua các góc bo tròn của hộp Floating Card.
**Giải pháp:** Di chuyển toàn bộ cấu hình `padding` vào bên trong thẻ `ScrollView` và ép cấu trúc cắt vùng đồ họa thừa bằng thuộc tính `android:clipToPadding="true"`.
```xml
<!-- LỚP 3: BẢNG SLIDERS FLOATING (Nằm gọn bên trong ảnh cameraContainer) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardAdvancedSliders"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="24dp"
app:cardCornerRadius="12dp"
app:cardElevation="8dp"
android:visibility="gone"
app:strokeWidth="0dp"
android:backgroundTint="#A6000000">
<!-- FIX LỖI ĐÈ KHUNG: Đưa padding và clipToPadding vào ScrollView -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:scrollbars="vertical"
android:padding="16dp"
android:clipToPadding="true"> <!-- Ép nội dung cuộn phải biến mất khi chạm tới viền bo góc -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Tiêu đề & Nút X (Đóng) -->
<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:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="15sp" />
<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>
<!-- ================= CẬP NHẬT DẢI GIÁ TRỊ TOÀN BỘ SLIDERS (-100 đến 100) ================= -->
<!-- Brightness -->
<TextView android:id="@+id/tvLabelBrightness" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp"/>
<com.google.android.material.slider.Slider
android:id="@+id/sliderBrightness"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="-100.0"
android:valueTo="100.0"
android:value="0.0"
app:thumbColor="#FF9800"
app:trackColorActive="#FF9800"/>
<!-- Temperature -->
<TextView android:id="@+id/tvLabelTemperature" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="11sp"/>
<com.google.android.material.slider.Slider
android:id="@+id/sliderTemperature"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="-100.0"
android:valueTo="100.0"
android:value="0.0"
app:thumbColor="#FF9800"
app:trackColorActive="#FF9800"/>
<!-- Áp dụng tương tự cho tất cả 12 thanh Sliders còn lại cấu hình valueFrom="-100.0" và valueTo="100.0" -->
<!-- Đối với các thuộc tính đi từ 0 (Vignette, Grain, Faded Black) thì cấu hình valueFrom="0.0" và valueTo="100.0" -->
<!-- NÚT LƯU THÀNH PRESET MỚI -->
<Button
android:id="@+id/btnSaveCustomPreset"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="20dp"
android:text="LƯU THÀNH PRESET MỚI"
android:textColor="#FFFFFF"
android:backgroundTint="#FF9800"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>
</com.google.android.material.card.MaterialCardView>
```
---
## 💻 2. Chuẩn Hóa Logic Gán Hàm Độc Lập & Quy Đổi Tỷ Lệ Giá Trị (Kotlin)
**Giải pháp sửa lỗi gán sai thông số:** Quy hoạch lại chính xác từng sự kiện lắng nghe để tránh việc copy-paste mã lệnh bị trùng lặp biến.
**Giải pháp dải giá trị lớn:** Người dùng nhìn thấy và kéo trượt trên thang đo nguyên trực quan từ `-100` đến `+100` (để thấy tác động lớn, rõ rệt trên UI). Tuy nhiên, trước khi gán dữ liệu vào hàm `applyLiveFilterToCamera` để GPU xử lý, ta phải **chia cho 100** để đưa về dải số thực chuẩn hóa (`-1.0` đến `1.0`) giúp lõi phần cứng đồ họa không bị lỗi tính toán (vì Shader/CoreImage nhận giá trị số thực từ `-1` đến `1`).
```kotlin
private fun setupSliderListeners() {
// FIX 1: Chuẩn hóa gán đúng hàm cho Brightness
binding.sliderBrightness.addOnChangeListener { _, value, _ ->
// Hiển thị số nguyên (-100 đến 100) lên nhãn văn bản cho người dùng thấy
binding.tvLabelBrightness.text = String.format("Brightness (Độ sáng): %.0f", value)
// Quy đổi tỉ lệ về dải số thực (-1.0 đến 1.0) để gán cho Object cấu hình
val normalizedValue = value / 100f
editingPreset?.basic?.brightness = normalizedValue
// Đẩy giá trị ĐÚNG của Brightness sang bộ lọc GPU
applyLiveFilterToCamera(editingPreset)
}
// FIX 2: Chuẩn hóa gán đúng hàm cho Temperature (Không bị nhầm với Brightness nữa)
binding.sliderTemperature.addOnChangeListener { _, value, _ ->
binding.tvLabelTemperature.text = String.format("Temperature (Nhiệt màu): %.0f", value)
val normalizedValue = value / 100f
editingPreset?.basic?.temperature = normalizedValue // Gán chuẩn xác cho biến temperature
applyLiveFilterToCamera(editingPreset)
}
// FIX 3: Chuẩn hóa gán đúng hàm cho Vibrance
binding.sliderVibrance.addOnChangeListener { _, value, _ ->
binding.tvLabelVibrance.text = String.format("Vibrance (Bão hòa thông minh): %.0f", value)
val normalizedValue = value / 100f
editingPreset?.basic?.vibrance = normalizedValue // Gán chuẩn xác cho biến vibrance
applyLiveFilterToCamera(editingPreset)
}
// FIX 4: Chuẩn hóa cho hiệu ứng nhiễu hạt Film Grain (Dải giá trị từ 0 đến 100)
binding.sliderGrainAmount.addOnChangeListener { _, value, _ ->
binding.tvLabelGrainAmount.text = String.format("Grain Amount (Mật độ hạt): %.0f%%", value)
val normalizedValue = value / 100f
editingPreset?.grain?.grainAmount = normalizedValue // Gán chuẩn xác cho biến grainAmount
applyLiveFilterToCamera(editingPreset)
}
// Tiến hành rà soát lại tất cả các khối listener còn lại đảm bảo đúng biến nào - hàm đó!
}
```
---
## 🔄 3. Cơ Chế Nạp Lại Giá Trị Gốc Lên Sliders (Quy Đổi Ngược Nhân 100)
Khi người dùng mở bảng chỉnh sửa, chúng ta phải làm một phép tính ngược lại: Lấy giá trị số thực từ tệp JSON, **nhân với 100** để hiển thị đúng vị trí con trỏ trên thanh kéo `-100 -> +100`.
```kotlin
fun openAdvancedSliders(selectedPreset: ColorPreset) {
editingPreset = selectedPreset.deepCopy()
binding.tvEditPresetName.text = "Chỉnh sửa: ${editingPreset!!.name}"
// Đọc giá trị số thực từ JSON, nhân 100 để hiển thị dải số nguyên rõ rệt trên thanh trượt
binding.sliderBrightness.value = editingPreset!!.basic.brightness * 100f
binding.sliderTemperature.value = editingPreset!!.basic.temperature * 100f
binding.sliderVibrance.value = editingPreset!!.basic.vibrance * 100f
binding.sliderClarity.value = editingPreset!!.advanced.clarity * 100f
binding.sliderDehaze.value = editingPreset!!.advanced.dehaze * 100f
binding.sliderGrainAmount.value = editingPreset!!.grain.grainAmount * 100f
binding.sliderGrainSize.value = editingPreset!!.grain.grainSize * 100f
// Cập nhật text nhãn ban đầu
binding.tvLabelBrightness.text = String.format("Brightness (Độ sáng): %.0f", binding.sliderBrightness.value)
binding.tvLabelTemperature.text = String.format("Temperature (Nhiệt màu): %.0f", binding.sliderTemperature.value)
setupSliderListeners()
binding.cardAdvancedSliders.visibility = View.VISIBLE
}
```
---
## 📅 4. Các Đầu Việc Cần Hoàn Thành (Checklist)
* [ ] Thực hiện thay đổi cấu trúc `ScrollView` trong tệp XML để cô lập hoàn toàn nội dung cuộn, giải quyết triệt để lỗi tràn khung hiển thị.
* [ ] Tiến hành cập nhật lại toàn bộ 14 hàm lắng nghe sự kiện `addOnChangeListener`, rà soát kỹ lưỡng từng dòng lệnh để đảm bảo tính độc lập tuyệt đối giữa các thông số.
* [ ] Chạy thử nghiệm kéo trượt mạnh về hai cực đầu mút (`-100``100`) trên thiết bị thật để kiểm tra độ trễ phản hồi hình ảnh và biên độ thay đổi của màu ảnh xem đã đủ rõ rệt chưa.
+87
View File
@@ -0,0 +1,87 @@
# 🛠️ KẾ HOẠCH SỬA LỖI ĐỒ HỌA: CHUẨN HÓA THUẬT TOÁN ĐỔI MÀU THEO KÊNH
Mục tiêu: Quy hoạch lại công thức toán học tính toán 4 chỉ số `alpha`, `r`, `g`, `b` để đảm bảo:
* **Brightness:** Phải tăng/giảm đều cả 3 kênh R, G, B (độ sáng tổng thể).
* **Temperature:** Chỉ dịch chuyển cân bằng giữa kênh R (Ấm) và kênh B (Lạnh).
* **Tint:** Chỉ dịch chuyển cân bằng kênh G (Xanh lá) và tổ hợp R+B (Hồng dâu).
* **Alpha (Độ đậm bộ lọc):** Được điều tiết một cách thông minh bằng Saturation, Vibrance, Contrast, Clarity và Dehaze để tăng giảm độ dày của lớp phủ màu Instax.
---
## 💻 1. Cấu Trúc Mã Nguồn Sau Khi Sửa Đổi (Kotlin)
Hãy thay thế toàn bộ đoạn tính toán màu ARGB cũ bằng khối thuật toán chuẩn hóa và loại bỏ hoàn toàn các dòng khai báo trùng lặp:
```kotlin
val color = if (preset.isEditable) {
// 1. ALPHA (Độ đậm/Mờ của lớp filter): Được quyết định bởi Saturation, Vibrance và hiệu ứng nâng cao
// Quy đổi dải từ -1.0 -> 1.0 sang mức độ trong suốt hợp lý (từ 15 đến 140 trong hệ 255)
val baseAlpha = 60f
val saturationImpact = (preset.basic.saturation + preset.basic.vibrance) * 25f
val contrastImpact = preset.basic.contrast * 15f
val alpha = (baseAlpha + saturationImpact + contrastImpact).toInt().coerceIn(15, 140)
// 2. BASE COLOR (Màu nền mặc định ban đầu trước khi tinh chỉnh)
// Bắt đầu từ một màu trung tính hoặc lấy màu gốc của Theme nếu có
var baseR = 140f
var baseG = 130f
var baseB = 120f
// 3. BRIGHTNESS (Độ sáng): Bắt buộc phải cộng/trừ ĐỀU vào cả 3 kênh R, G, B để dịch chuyển Exposure
val brightnessOffset = preset.basic.brightness * 80f // Nhân hệ số để biên độ thay đổi từ -100 đến +100 rõ rệt
baseR += brightnessOffset
baseG += brightnessOffset
baseB += brightnessOffset
// 4. TEMPERATURE (Nhiệt màu): + là Ấm (Tăng Đỏ, Giảm Lam), - là Lạnh (Giảm Đỏ, Tăng Lam)
val tempOffset = preset.basic.temperature * 50f
baseR += tempOffset
baseB -= tempOffset
// 5. TINT (Sắc độ): + là Ám hồng (Tăng Đỏ & Lam, Giảm Lục), - là Ám xanh (Giảm Đỏ & Lam, Tăng Lục)
val tintOffset = preset.basic.tint * 40f
baseG -= tintOffset
baseR += (tintOffset * 0.5f)
baseB += (tintOffset * 0.5f)
// 6. TONE CURVE & SHADOWS TINT (Ám màu vùng tối đặc trưng của Instax)
baseR += (preset.toneCurve.shadowsTintR * 35f)
baseG += (preset.toneCurve.shadowsTintG * 35f)
baseB += (preset.toneCurve.shadowsTintB * 35f)
// Nâng đáy màu đen (Faded Black) tạo độ sờn ảnh
val fadeOffset = preset.toneCurve.fadedBlackLevel * 30f
baseR += fadeOffset
baseG += fadeOffset
baseB += fadeOffset
// 7. CLARITY & DEHAZE (Điều tiết thêm độ tương phản cục bộ của màu)
val advancedOffset = (preset.advanced.clarity - preset.advanced.dehaze) * 20f
baseR += advancedOffset
baseG += advancedOffset
baseB += advancedOffset
// 8. ÉP BIÊN ĐỘ (COERCE) để đảm bảo giá trị RGB luôn nằm trong giới hạn vật lý 0 -> 255
val r = baseR.toInt().coerceIn(0, 255)
val g = baseG.toInt().coerceIn(0, 255)
val b = baseB.toInt().coerceIn(0, 255)
android.graphics.Color.argb(alpha, r, g, b)
} else {
// Khối xử lý cho các mẫu mặc định cố định không sửa đổi
when (preset.id) {
"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.argb(0, 0, 0, 0)
}
}
```
---
+163
View File
@@ -0,0 +1,163 @@
# 🎨 KẾ HOẠCH THAY ĐỔI UX: GIẢI PHÓNG TẦM NHÌN VỚI THÀNH TRƯỢT THƯỚC ĐO TỐI GIẢN
Mục tiêu: Loại bỏ hoàn toàn khối Card View mờ lọt lòng cũ. Khi người dùng nhấn giữ một Preset (hoặc nhấn nút Edit nhỏ), khu vực điều khiển camera phía dưới sẽ biến đổi thành thanh tinh chỉnh thông số 3 tầng dạng nén tối giản bám sát theo sơ đồ ảnh `image.png`.
---
## 📐 1. Tái Cấu Trúc Lại XML Layout Hạ Tầng (`activity_main.xml`)
Bảng Sliders cũ sẽ bị xóa bỏ. Thay vào đó, ta tạo một cụm layout tinh chỉnh mới có tên `layoutTimelineEditor`. Mặc định cụm này sẽ ẩn đi (`android:visibility="gone"`). Khi kích hoạt, nó sẽ đè lên vị trí của bảng chọn Frame/Preset và Control Panel cũ.
```xml
<!-- KHU VỰC ĐIỀU KHIỂN CHỈNH SỬA ẢNH (Thay thế Control Panel khi nhấn giữ preset) -->
<ConstraintLayout
android:id="@+id/layoutTimelineEditor"
android:layout_width="match_parent"
android:layout_height="180dp"
android:background="#000000"
android:visibility="gone"
app:layout_bottom_toBottomOf="parent">
<!-- TẦNG 1: THANH TRƯỢT THƯỚC ĐO ĐỘC ĐÁO (RULER SLIDER) -->
<!-- Hiển thị vạch chia số và kim chỉ vị trí trung tâm 0 như ảnh image_44676a.png -->
<FrameLayout
android:id="@+id/rulerContainer"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toTopOf="parent">
<!-- Các vạch thước kẻ nhỏ (Có thể tự vẽ bằng Custom View hoặc dùng ảnh lặp) -->
<ImageView
android:id="@+id/imgRulerTicks"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@drawable/bg_ruler_ticks"
android:scaleType="centerCrop" />
<!-- Kim chỉ số 0 màu cam chính giữa cố định -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center">
<TextView
android:id="@+id/tvRulerValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="#FF9800"
android:textSize="12sp"
android:textStyle="bold"
android:layout_gravity="center_horizontal"/>
<ImageView
android:layout_width="12dp"
android:layout_height="12dp"
android:src="@drawable/ic_arrow_down_orange"
android:layout_gravity="center_horizontal"/>
</LinearLayout>
</FrameLayout>
<!-- TẦNG 2: PHẦN HIỂN THỊ ICON CÁC THUỘC TÍNH (HORIZONTAL LIST) -->
<!-- Chứa danh sách cuộn ngang các nút: Brightness, Contrast, Saturation... -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvAttributeIcons"
android:layout_width="match_parent"
android:layout_height="50dp"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/rulerContainer"
app:layout_constraintBottom_toTopOf="@id/layoutEditorActionButtons" />
<!-- TẦNG 3: THANH THAO TÁC HOÀN THÀNH (HỦY / ĐỒNG Ý) -->
<RelativeLayout
android:id="@+id/layoutEditorActionButtons"
android:layout_width="match_parent"
android:layout_height="48dp"
android:paddingStart="20dp"
android:paddingEnd="20dp"
app:layout_constraintBottom_toBottomOf="parent">
<!-- Nút X (Hủy bỏ các thông số vừa chỉnh) -->
<ImageButton
android:id="@+id/btnEditorClose"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@null"
android:src="@drawable/ic_close_white"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"/>
<!-- Nút Dấu Tích (Đồng ý để lưu preset) -->
<ImageButton
android:id="@+id/btnEditorCheck"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@null"
android:src="@drawable/ic_check_white"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"/>
</RelativeLayout>
</ConstraintLayout>
```
---
## 💻 2. Kịch Bản Thao Tác Cử Chỉ Động (Kotlin Logic)
Hệ thống điều khiển cử chỉ mới sẽ hoạt động dựa trên hai tương tác chính: **Chọn thuộc tính****Bắt sự kiện vuốt trên ảnh**.
### 🔹 2.1. Quản lý Thuộc tính được Chọn (Tầng 2)
Tạo một biến Enum để nhận biết người dùng đang nhấn vào thuộc tính nào trong `rvAttributeIcons`:
```kotlin
enum class EditAttribute { BRIGHTNESS, CONTRAST, SATURATION, VIBRANCE, TEMPERATURE }
private var currentAttribute = EditAttribute.BRIGHTNESS // Mặc định là độ sáng
```
Khi người dùng bấm chọn một icon trong danh sách, icon đó sẽ **đổi sang màu cam** để thông báo trạng thái hoạt động, đồng thời nạp giá trị hiện tại của thuộc tính đó lên kim chỉ thước đo `tvRulerValue`.
### 🔹 2.2. Cơ chế Swipe trên ảnh để đổi giá trị (Gesture Detector)
Đính kèm một bộ lắng nghe cử chỉ vuốt chạm (`OnTouchListener`) trực tiếp vào khung hình Camera chính (`cameraContainer`).
1. **Bước 1: Bắt sự kiện Touch:** Chạm màn hình.
Người dùng đặt ngón tay lên vùng hiển thị ảnh và vuốt sang trái hoặc phải. Hệ thống ghi lại điểm chạm đầu tiên `startX`.
2. **Bước 2: Quy đổi độ dời sang giá trị số:** Tính khoảng cách.
Khi ngón tay di chuyển, tính toán khoảng cách chênh lệch `deltaX = currentX - startX`. Quy đổi khoảng cách pixel này sang dải giá trị nguyên từ `-100` đến `+100`.
3. **Bước 3: Dịch chuyển vạch thước kẻ & Áp bộ lọc:** Cập nhật đồ họa.
Dựa vào giá trị `deltaX`:
* Di chuyển tịnh tiến hình ảnh vạch thước `imgRulerTicks` sang trái hoặc phải để tạo cảm giác trượt thực tế.
* Cập nhật số trên kim chỉ `tvRulerValue`.
* Gán giá trị mới vào thuộc tính đang chọn (ví dụ: `editingPreset.basic.brightness`) và ra lệnh cho GPU render cập nhật màu sắc ngay lập tức trên ảnh.
---
## 💾 3. Lưu Preset Với Tên Mới Khi Ấn Đồng Ý
Khi người dùng nhấn vào nút **Dấu tích (Đồng ý để lưu preset)** ở góc dưới bên phải màn hình chỉnh sửa:
1. App sẽ hiển thị một thông báo popup (Dialog) gọn gàng hỏi tên bộ lọc mới.
2. Tiến hành đóng gói file JSON chứa toàn bộ 14 thông số hoàn chỉnh.
3. Ẩn thanh `layoutTimelineEditor`, mở lại giao diện Control Panel chụp ảnh thông thường, đồng thời tải lại thanh timeline chính để preset mới xuất hiện ngay lập tức.
---
## 📅 4. Các Đầu Việc Cần Làm Ngay (Checklist)
* [ ] **Chuẩn bị Icon Đơn Sắc:** Tìm và sử dụng các icon vector SVG tiêu chuẩn và tối giản cho nút Độ sáng (Mặt trời), Tương phản (Hình tròn chia đôi), Bão hòa (Vòng tròn màu)... để sử dụng cho các nút
* [ ] **Thiết lập Custom View cho Thước Đo:** Code logic dịch chuyển tọa độ X (`translationX`) của hình ảnh vạch thước `imgRulerTicks` đồng bộ theo tỷ lệ ngón tay vuốt trên màn hình.
* [ ] **Gán cử chỉ gỡ lỗi:** Đảm bảo khi người dùng thao tác vuốt trên ảnh, các cử chỉ của hệ thống camera (như zoom bằng 2 ngón tay) tạm thời bị khóa lại để không làm nhiễu loạn quá trình chỉnh màu.
+151
View File
@@ -0,0 +1,151 @@
# 🎨 KẾ HOẠCH BỔ SUNG THUỘC TÍNH EDITOR & HIỂN THỊ TÊN ĐỘNG TRÊN TOP BAR
Mục tiêu: Đưa toàn bộ 14 thông số cấu hình vào `rvAttributeIcons` dạng danh sách cuộn ngang không giới hạn. Đồng bộ hóa việc chọn icon để hiển thị tên tiếng Việt tương ứng lên vùng văn bản phía trên ảnh và gán chính xác sự kiện vuốt trên ảnh cho thuộc tính đó.
---
## 📐 1. Cấu Trúc Lại XML Layout Để Thêm Dải Hiển Thị Tên (`activity_main.xml`)
Bám sát ảnh `image.png`, chúng ta cần thêm một `TextView` nằm ở dải đen phía trên bức ảnh để làm nhiệm vụ hiển thị tên nút đang chọn.
```xml
<!-- DẢI ĐEN PHÍA TRÊN ẢNH CAMERA (Như mô tả trong image.png) -->
<RelativeLayout
android:id="@+id/layoutEditorTitleBar"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="#000000"
app:layout_bottom_toTopOf="@id/cameraContainer"
app:layout_constraintTop_toBottomOf="@id/topBar">
<!-- Văn bản hiển thị động tên nút đang được chọn -->
<TextView
android:id="@+id/tvCurrentAttributeName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="Brightness (Độ sáng)" <!-- Tên đng hin th đây -->
android:textColor="#FF9800" <!-- Màu cam nổi bật hoặc trắng tùy chọn -->
android:textSize="14sp"
android:textStyle="bold" />
</RelativeLayout>
```
---
## 📁 2. Khai Báo Toàn Bộ Bản Đồ Thuộc Tính & Icons (Kotlin Model)
Để quản lý 14 nút thuộc tính một cách khoa học, chúng ta tạo một danh sách Model bao gồm: ID thuộc tính, Tên hiển thị (Tiếng Việt), và File định dạng Icon tương ứng trong `res/drawable`.
### 🔹 2.1. Định nghĩa Data Class cho nút thuộc tính
```kotlin
data class AttributeNode(
val type: EditAttribute,
val displayName: String,
val iconResId: Int
)
enum class EditAttribute {
BRIGHTNESS, CONTRAST, SATURATION, VIBRANCE, TEMPERATURE, TINT, // Nhóm 1: Cơ bản
CLARITY, DEHAZE, VIGNETTE_AMOUNT, // Nhóm 2: Nâng cao
FADED_BLACK_LEVEL, SHADOWS_TINT_R, SHADOWS_TINT_G, SHADOWS_TINT_B, // Nhóm 3: Tone Curve
GRAIN_AMOUNT, GRAIN_SIZE // Nhóm 4: Nhiễu hạt
}
```
### 🔹 2.2. Khởi tạo mảng dữ liệu 14 nút đầy đủ
```kotlin
val editorAttributes = listOf(
AttributeNode(EditAttribute.BRIGHTNESS, "Brightness (Độ sáng)", R.drawable.ic_attr_brightness),
AttributeNode(EditAttribute.CONTRAST, "Contrast (Tương phản)", R.drawable.ic_attr_contrast),
AttributeNode(EditAttribute.SATURATION, "Saturation (Bão hòa màu)", R.drawable.ic_attr_saturation),
AttributeNode(EditAttribute.VIBRANCE, "Vibrance (Bão hòa thông minh)", R.drawable.ic_attr_vibrance),
AttributeNode(EditAttribute.TEMPERATURE, "Temperature (Nhiệt màu)", R.drawable.ic_attr_temperature),
AttributeNode(EditAttribute.TINT, "Tint (Sắc độ hồng/xanh)", R.drawable.ic_attr_tint),
AttributeNode(EditAttribute.CLARITY, "Clarity (Độ rõ nét)", R.drawable.ic_attr_clarity),
AttributeNode(EditAttribute.DEHAZE, "Dehaze (Độ trong suốt / Sương mù)", R.drawable.ic_attr_dehaze),
AttributeNode(EditAttribute.VIGNETTE_AMOUNT, "Vignette Amount (Bo tối góc)", R.drawable.ic_attr_vignette),
AttributeNode(EditAttribute.FADED_BLACK_LEVEL, "Faded Black Level (Độ mờ vùng đen)", R.drawable.ic_attr_fade),
AttributeNode(EditAttribute.SHADOWS_TINT_R, "Shadows Tint R (Sắc Đỏ vùng tối)", R.drawable.ic_attr_tint_r),
AttributeNode(EditAttribute.SHADOWS_TINT_G, "Shadows Tint G (Sắc Lục vùng tối)", R.drawable.ic_attr_tint_g),
AttributeNode(EditAttribute.SHADOWS_TINT_B, "Shadows Tint B (Sắc Lam vùng tối)", R.drawable.ic_attr_tint_b),
AttributeNode(EditAttribute.GRAIN_AMOUNT, "Grain Amount (Mật độ hạt)", R.drawable.ic_attr_grain_amount),
AttributeNode(EditAttribute.GRAIN_SIZE, "Grain Size (Kích thước hạt)", R.drawable.ic_attr_grain_size)
)
```
---
## 💻 3. Xử Lý Logic Tương Tác & Đồng Bộ Trạng Thái Động
Khi gán danh sách này vào `rvAttributeIcons` dưới dạng LayoutManager cuộn ngang (`LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)`), người dùng có thể thoải mái vuốt lướt sang phải để tìm các nút bị ẩn như mũi tên chỉ dẫn trong ảnh `image.png`.
### 🔹 3.1. Sự kiện Click đổi màu và đồng bộ Tên lên Top Bar
Trong sự kiện bấm chọn một Icon thuộc tính bên trong Adapter của RecyclerView:
```kotlin
fun onAttributeSelected(selectedNode: AttributeNode) {
// 1. Lưu lại thuộc tính đang chỉnh sửa hiện tại
currentAttribute = selectedNode.type
// 2. Cập nhật văn bản lên dải đen phía trên ảnh đúng theo thiết kế image_44ee6b.jpg
binding.tvCurrentAttributeName.text = selectedNode.displayName
// 3. Đọc giá trị hiện tại của thuộc tính này trong file JSON tạm thời để nạp lên thước đo
val currentValue = getCurrentAttributeValue(selectedNode.type)
binding.tvRulerValue.text = String.format("%.0f", currentValue * 100f)
// 4. Ra lệnh cho Adapter vẽ lại (Notify) để đổi màu Icon được chọn thành MÀU CAM
attributeAdapter.updateSelection(selectedNode.type)
}
```
### 🔹 3.2. Cập nhật hàm điều phối Vuốt trên ảnh (Swipe Logic Matrix)
Hàm tiếp nhận giá trị `deltaX` khi người dùng vuốt trên màn hình ảnh giờ đây sẽ phân nhánh chính xác sang cả 14 thông số, không còn tình trạng gán nhầm hàm:
```kotlin
private fun updateAttributeBySwipe(newValue: Float) {
// newValue có dải giá trị từ -1.0f đến 1.0f sau khi đã chia cho 100
when (currentAttribute) {
EditAttribute.BRIGHTNESS -> editingPreset?.basic?.brightness = newValue
EditAttribute.CONTRAST -> editingPreset?.basic?.contrast = newValue
EditAttribute.SATURATION -> editingPreset?.basic?.saturation = newValue
EditAttribute.VIBRANCE -> editingPreset?.basic?.vibrance = newValue
EditAttribute.TEMPERATURE -> editingPreset?.basic?.temperature = newValue
EditAttribute.TINT -> editingPreset?.basic?.tint = newValue
EditAttribute.CLARITY -> editingPreset?.advanced?.clarity = newValue
EditAttribute.DEHAZE -> editingPreset?.advanced?.dehaze = newValue
EditAttribute.VIGNETTE_AMOUNT -> editingPreset?.advanced?.vignetteAmount = newValue.coerceIn(0f, 1f)
EditAttribute.FADED_BLACK_LEVEL -> editingPreset?.toneCurve?.fadedBlackLevel = newValue.coerceIn(0f, 1f)
EditAttribute.SHADOWS_TINT_R -> editingPreset?.toneCurve?.shadowsTintR = newValue.coerceIn(0f, 1f)
EditAttribute.SHADOWS_TINT_G -> editingPreset?.toneCurve?.shadowsTintG = newValue.coerceIn(0f, 1f)
EditAttribute.SHADOWS_TINT_B -> editingPreset?.toneCurve?.shadowsTintB = newValue.coerceIn(0f, 1f)
EditAttribute.GRAIN_AMOUNT -> editingPreset?.grain?.grainAmount = newValue.coerceIn(0f, 1f)
EditAttribute.GRAIN_SIZE -> editingPreset?.grain?.grainSize = newValue.coerceIn(0f, 1f)
}
// Luôn luôn gọi render cập nhật đồ họa thời gian thực lên Camera ngay lập tức
applyLiveFilterToCamera(editingPreset)
}
```
---
## 📅 4. Các Đầu Việc Cần Triển Khai (Checklist)
* [ ] **Chuẩn bị 14 cặp Icons tương ứng:** Tạo hoặc import các asset dạng vector đơn sắc trắng vào thư mục dự án trên Linux.
* [ ] **Cấu hình `clipToPadding="false"` cho RecyclerView:** Thiết lập thuộc tính này cho thanh icon để khi cuộn, các icon ở hai đầu rìa có khoảng không trượt mượt mà, không bị cắt cụt đột ngột.
* [ ] **Kiểm thử liên kết:** Đảm bảo khi mở màn hình Editor lên, mặc định app sẽ tự động giả lập click vào nút đầu tiên (Brightness), icon sáng cam và dải chữ phía trên hiển thị đúng chữ `"Brightness (Độ sáng)"` như ảnh `image.png`.
+158
View File
@@ -0,0 +1,158 @@
# 🎨 KẾ HOẠCH TÁI CẤU TRÚC UX/UI: TỐI GIẢN & TƯƠNG TÁC ĐỘNG
Mục tiêu: Chuyển đổi giao diện sang phong cách tối giản (không viền, không nền), sử dụng màu sắc để biểu thị trạng thái (Trắng ➔ Cam) và tối ưu hóa không gian hiển thị bằng cách gộp timeline của Frame và Presets.
---
## 📐 1. Cập Nhật Cấu Trúc Giao Diện (XML Layout)
Chúng ta sẽ điều chỉnh file `activity_main.xml` để loại bỏ các khối nền thừa và cấu trúc lại các cụm nút dựa theo `image_29a4a7.png`.
### 🔹 1.1. Top Bar (Các Icon Trạng Thái)
Thay thế các `Button` có text thành `ImageButton` chỉ chứa icon (Tia sét, Đồng hồ, Vòng xoay camera).
* **Style:** Không nền (`android:background="@null"`), không bo viền.
* **Màu sắc:** Sử dụng `app:tint="@color/white"` làm mặc định.
```xml
<LinearLayout
android:id="@+id/topBar" ...>
<ImageButton
android:id="@+id/btnFlash"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@null"
android:src="@drawable/ic_flash"
app:tint="@color/white" />
<!-- Tương tự cho btnTimer (ic_timer) và btnSwitchCamera (ic_flip_camera) -->
</LinearLayout>
```
### 🔹 1.2. Nút Frame và Nút Presets (Toggle Buttons)
Đây là hai nút chính nằm trên cụm Zoom. Cần thiết kế dưới dạng `ImageView` (hoặc `CardView` có bo góc nhẹ nếu muốn chứa ảnh mượt hơn) để có thể thay đổi ảnh đại diện (thumbnail) khi người dùng chọn.
```xml
<LinearLayout
android:id="@+id/toggleModeContainer" ...>
<!-- Nút Frame -->
<ImageView
android:id="@+id/btnModeFrame"
android:layout_width="60dp"
android:layout_height="60dp"
android:src="@drawable/ic_default_frame_thumbnail" />
<!-- Nút Presets -->
<ImageView
android:id="@+id/btnModePreset"
android:layout_width="60dp"
android:layout_height="60dp"
android:src="@drawable/ic_default_preset_thumbnail" />
</LinearLayout>
<!-- Khung Timeline dùng chung (sẽ thay đổi Adapter tùy thuộc vào nút nào được bấm) -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSharedTimeline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
```
### 🔹 1.3. Cụm Zoom (0.5x, 1x, 3x)
Thay thế các nút nền xám hiện tại thành `TextView` văn bản thuần túy.
* **Style:** Chữ đậm, không nền (`android:background="@null"`).
* **Trạng thái màu:** Mặc định màu trắng (`#FFFFFF`), khi chọn đổi sang màu cam (`#FF9800`).
### 🔹 1.4. Bottom Panel (Thêm nút Play/Review)
Dựa theo vị trí khoanh đỏ ở góc dưới bên trái của `image_29a4a7.png`, thêm nút xem lại ảnh.
```xml
<RelativeLayout
android:id="@+id/controlPanel" ...>
<!-- Nút Play (Xem lại ảnh) -->
<ImageButton
android:id="@+id/btnReviewGallery"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_marginStart="24dp"
android:background="@null"
android:src="@drawable/ic_play_gallery" />
<!-- Nút Chụp (Ở giữa) -->
<!-- ... -->
</RelativeLayout>
```
---
## 💻 2. Kịch Bản Logic & Quản Lý Trạng Thái (Kotlin)
### 🔹 2.1. Logic Đổi Màu Icon Top Bar và Zoom (Trắng ➔ Cam)
Tạo các hàm helper để xử lý đổi màu nhanh gọn bằng `ColorStateList` hoặc set `tint` trực tiếp:
```kotlin
// Ví dụ đổi màu icon Flash
fun updateFlashIconState(isActive: Boolean) {
val color = if (isActive) Color.parseColor("#FF9800") else Color.WHITE
binding.btnFlash.imageTintList = ColorStateList.valueOf(color)
}
// Ví dụ đổi màu text Zoom
fun updateZoomTextState(selectedZoom: TextView) {
// Reset tất cả về trắng
binding.tvZoom05.setTextColor(Color.WHITE)
binding.tvZoom1.setTextColor(Color.WHITE)
binding.tvZoom3.setTextColor(Color.WHITE)
// Set màu cam cho mức zoom được chọn
selectedZoom.setTextColor(Color.parseColor("#FF9800"))
}
```
### 🔹 2.2. Logic Toggle Giữa Frame và Presets
Để đáp ứng yêu cầu: Bấm Frame thì ẩn Presets (và ngược lại), chúng ta quản lý trạng thái hiển thị bằng `View.VISIBLE``View.GONE`.
1. **Khi nhấn nút Frame:** Mở Timeline Frame.
Ẩn nút Presets (`btnModePreset.visibility = View.GONE`).
Hiển thị `rvSharedTimeline`. Set Adapter của RecyclerView thành danh sách các Frames.
*(Lưu ý: Bạn nên cấu hình để khi nhấn nút Frame lần nữa, Timeline sẽ đóng lại và nút Presets hiện ra lại để người dùng có thể chuyển đổi).*
2. **Khi nhấn nút Presets:** Mở Timeline Presets.
Ẩn nút Frame (`btnModeFrame.visibility = View.GONE`).
Hiển thị `rvSharedTimeline`. Set Adapter của RecyclerView thành danh sách các Preset màu.
3. **Khi chọn phần tử trong Timeline:** Cập nhật Thumbnail.
Nếu đang ở chế độ Frame và người dùng chọn "Vintage Frame", lấy ảnh thumbnail của frame đó gán đè vào icon của nút Frame: `btnModeFrame.setImageBitmap(selectedFrameThumb)`. Tương tự cho Presets.
### 🔹 2.3. Logic Nút "Play" (Xem lại ảnh)
Khi nhấn vào `btnReviewGallery`, sử dụng `Intent` để mở một Activity mới (`GalleryActivity`) hiển thị lưới (Grid) các ảnh đã chụp (đọc từ thư mục lưu trữ mà bạn đã cấu hình ở các bước trước). Hoặc đơn giản nhất là mở thẳng thư viện mặc định của điện thoại thông qua `ACTION_VIEW`.
---
## 📅 3. Các Đầu Việc Cần Làm (Checklist)
* [ ] **Chuẩn bị Assets (SVG/Vector):** Tìm/Tạo các icon dạng vector (XML) cho `ic_flash`, `ic_timer`, `ic_flip_camera`, `ic_play`. Sử dụng vector giúp ảnh không bị vỡ và dễ dàng đổi màu bằng thuộc tính `tint` trên Android.
* [ ] **Cập nhật Layout:** Mở file `activity_main.xml` và xóa các thuộc tính background cũ, thay thế bằng các thuộc tính như kế hoạch trên.
* [ ] **Viết Helper Functions:** Trong `MainActivity.kt`, tạo các hàm `toggleTimelineMode(isFrameMode: Boolean)` để quản lý việc ẩn/hiện và tráo đổi Adapter cho 2 nút Frame/Presets.
* [ ] **Tạo Activity Review:** Tạo một màn hình đơn giản hoặc Fragment để hiển thị ảnh ngay khi bấm nút Play ở góc dưới cùng bên trái.
+126
View File
@@ -0,0 +1,126 @@
# 🎨 THIẾT KẾ HỆ THỐNG PRESET MÀU CHUYÊN SÂU (GIẢ LẬP PHIM INSTAX)
Mục tiêu: Định nghĩa một cấu trúc dữ liệu chuẩn hóa (JSON Schema) chứa đầy đủ các thông số xử lý ảnh nâng cao để áp dụng trực tiếp lên kính ngắm Camera thời gian thực và cho phép người dùng tự thiết kế custom preset.
---
## 📁 1. Cấu Trúc Dữ Liệu Tổng Hợp (JSON Schema)
Mọi tệp cấu hình Preset (nằm trong thư mục hệ thống `assets/presets/` hoặc do người dùng tự lưu trong bộ nhớ máy) sẽ tuân theo định dạng chuẩn hóa dưới đây. Các giá trị số được quy ước trong khoảng từ `-1.0f` đến `1.0f` để tối ưu cho việc tính toán đồ họa phần cứng.
```json
{
"id": "instax_faded_warm_01",
"name": "Instax Nắng Chiều",
"theme_category": "Retro Vintage",
"is_editable": true,
"basic_adjustments": {
"brightness": 0.05,
"contrast": -0.15,
"saturation": -0.20,
"vibrance": 0.25,
"temperature": 0.18,
"tint": -0.02
},
"advanced_effects": {
"clarity": -0.20,
"dehaze": -0.15,
"vignette_amount": 0.35
},
"tone_curve_emulation": {
"faded_black_level": 0.12,
"shadows_tint_r": 0.0,
"shadows_tint_g": 0.04,
"shadows_tint_b": 0.08
},
"film_grain": {
"grain_amount": 0.28,
"grain_size": 0.15
}
}
```
---
## ⚙️ 2. Giải Thích Ý Nghĩa Các Thông Số Trong Nhiếp Ảnh Chân Dung/Photobooth
Để giả lập chính xác chất ảnh Instax/Polaroid hoài cổ, ba thông số mới đóng vai trò quyết định cấu trúc của bức ảnh:
* **Vibrance (Bão hòa thông minh):** Khác với *Saturation* tăng màu đồng đều dễ làm cháy da người, *Vibrance* chỉ tăng cường các màu còn nhạt và chủ động né tông màu da. Công thức tạo màu phim luôn là: **[Giảm Saturation (-20%) + Tăng Vibrance (+25%)]** để màu bối cảnh trong trẻo mà da mặt nhân vật vẫn hồng hào tự nhiên.
* **Clarity (Độ rõ nét vùng Midtone):** Đối với phong cách Vintage/Instax mờ ảo, thông số này thường được đặt giá trị **Âm (Negative Clarity từ -15% đến -30%)**. Việc giảm Clarity sẽ làm dịu các vùng chuyển góc gắt, tạo hiệu ứng mịn da tự nhiên và làm bức ảnh có cảm giác "mềm mại", mơ màng giống ống kính nhựa của máy phim cổ.
* **Dehaze (Điều chỉnh độ trong suốt/Khói mù):** Khi đặt giá trị **Âm (Negative Dehaze từ -10% đến -25%)**, bộ lọc sẽ cố tình bơm thêm một lớp "sương mờ" nhẹ vào bức ảnh. Hiệu ứng này mô phỏng hiện tượng lọt sáng nhẹ (Light leak) hoặc sự suy giảm hóa chất của các tấm phim lấy liền theo thời gian.
---
## 💻 3. Lập Trình Lớp Đối Tượng Trên Android Native (Kotlin)
Tạo file `ColorPreset.kt` để quản lý các thông số này trong code logic ứng dụng:
```kotlin
package com.photobooth.app.model
data class ColorPreset(
val id: String,
var name: String,
val themeCategory: String,
val isEditable: Boolean,
val basic: BasicAdjustments,
val advanced: AdvancedEffects,
val toneCurve: ToneCurveEmulation,
val grain: FilmGrain
)
data class BasicAdjustments(
var brightness: Float, // -1.0f đến 1.0f
var contrast: Float, // -1.0f đến 1.0f
var saturation: Float, // -1.0f đến 1.0f
var vibrance: Float, // -1.0f đến 1.0f -> Bảo vệ màu da người
var temperature: Float, // -1.0f đến 1.0f -> Ấm (vàng) / Lạnh (xanh)
var tint: Float // -1.0f đến 1.0f -> Xanh lá / Hồng dâu
)
data class AdvancedEffects(
var clarity: Float, // -1.0f đến 1.0f -> Giảm để làm mịn thơ mộng
var dehaze: Float, // -1.0f đến 1.0f -> Giảm để tạo lớp sương hoài cổ
var vignetteAmount: Float // 0.0f đến 1.0f -> Bo tối 4 góc ảnh
)
data class ToneCurveEmulation(
var fadedBlackLevel: Float, // Nâng đáy màu đen thành xám sờn
var shadowsTintR: Float,
var shadowsTintG: Float,
var shadowsTintB: Float // Ám màu xanh rêu/xanh biển vào vùng tối
)
data class FilmGrain(
var grainAmount: Float, // Độ đậm hạt nhiễu
var grainSize: Float // Kích thước hạt nhiễu phim
)
```
---
## 🔄 4. Quy Trình Xử Lý Tương Tác Tùy Biến (Custom Workflow)
1. **Bước 1: Người dùng tinh chỉnh bộ thông số nâng cao:** Giao diện Slider.
Trong chế độ chỉnh sửa, ứng dụng hiển thị một danh sách các thanh cuộn dọc hoặc ngang (SeekBars/Sliders) tương ứng với: *Vibrance, Clarity, Dehaze, Grain...* Người dùng kéo chỉnh thay đổi giá trị trực tiếp.
2. **Bước 2: Áp dụng Real-time qua OpenGL ES / Vulkan:** Render Shader.
Mỗi khi Slider thay đổi, giá trị float mới được đẩy thẳng vào một chương trình đồ họa phần cứng (Custom Fragment Shader). GPU sẽ xử lý tính toán màu sắc, độ mịn (Clarity), và độ mờ (Dehaze) trên luồng Surface của CameraX ở tốc độ 60 khung hình/giây.
3. **Bước 3: Đóng gói và Ghi tệp JSON cá nhân:** Serialization.
Khi người dùng ấn "Lưu làm Preset của tôi", đối tượng `ColorPreset` chứa các thông số tùy biến mới sẽ được serialize sang chuỗi JSON bằng thư viện GSON. File này được ghi vào thư mục `context.filesDir` (bộ nhớ riêng an toàn của app).
---
## 📅 5. Danh Sách Các Việc Cần Làm Tiếp Theo (Checklist)
* [ ] **Xây dựng Custom Slider View:** Thiết kế các thanh trượt có phong cách tối giản với con trỏ (thumb) màu cam bám sát theo triết lý UX mới.
* [ ] **Tích hợp GPUImage cho Android:** Sử dụng thư viện `GPUImage` (hoặc viết OpenGL Shader thuần) để hiện thực hóa các bộ lọc toán học cho `VibranceFilter`, `ClarityFilter` (chuyển đổi dựa trên thuật toán Unsharp Mask) và `VignetteFilter`.
* [ ] **Đồng bộ hóa sang iOS:** Đảm bảo cấu trúc JSON này khi đưa sang iOS sẽ được phân tích (Parse) bằng giao thức `Decodable` của Swift thành một struct tương đương nhằm gọi các filter chuẩn của Apple như `CIVibrance``CILinearToSRGBToneCurve`.
+156
View File
@@ -0,0 +1,156 @@
# 🛠️ KẾ HOẠCH FIX BUG: TÁI CẤU TRÚC PHÂN TẦNG TIMELINE & CONTROL PANEL
Mục tiêu: Loại bỏ dải nền xám chứa các nút zoom. Di chuyển các nút zoom tích hợp chung vào khu vực điều khiển camera phía dưới (cùng hàng với nút Shutter và nút Xem lại). Thay đổi biểu tượng nút Play thành biểu tượng Image (Thư viện).
---
## 📐 1. Phác Thảo Cấu Trúc Giao Diện Mới (Sau khi chỉnh sửa)
Giao diện từ cạnh dưới kính ngắm camera trở xuống sẽ được tối giản thành đúng 2 khối:
```
[ Kính ngắm Camera chính (Tỉ lệ 3:4) ]
--------------------------------------------------
[ Khối 1: TIMELINE CONTAINER ]
- Nút Neo cố định ở cạnh trái (Frame / Palette)
- Thanh RecyclerView trượt ngang lướt dưới nút Neo
--------------------------------------------------
[ Khối 2: BOTTOM CONTROL PANEL ] (Không nền phụ, tối giản)
- Hàng trên: Các cụm văn bản Zoom thuần (0.5x, 1x, 3x) - Text trắng/cam
- Hàng dưới: [Icon Image/Gallery] ➔ [Nút Shutter Trắng/Cam] ➔ [Khoảng trống]
```
---
## 🛠️ 2. Chi Tiết Thay Đổi Trong Tệp Cấu Hình XML (`activity_main.xml`)
Bạn hãy thay thế toàn bộ khu vực phía dưới `cameraContainer` bằng đoạn mã tối ưu bằng `ConstraintLayout` dưới đây để giải quyết triệt để lỗi đè nền hiển thị trong ảnh `image_2afaba.png`.
```xml
<!-- ================= KHỐI 1: TIMELINE FRAMES & PRESETS ================= -->
<!-- Đặt ngay phía dưới Camera và không bị ai đè lên nữa -->
<FrameLayout
android:id="@+id/panelSelectionContainer"
android:layout_width="match_parent"
android:layout_height="80dp"
android:background="#000000"
app:layout_constraintTop_toBottomOf="@id/cameraContainer"
app:layout_constraintBottom_toTopOf="@id/bottomControlPanel">
<!-- Danh sách cuộn ngang -->
<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="75dp"
android:paddingEnd="16dp" />
<!-- Nút chức năng chính (Góc trái làm điểm Neo đè lên RecyclerView) -->
<ImageView
android:id="@+id/btnMainAnchor"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="left|center_vertical"
android:layout_marginStart="16dp"
android:src="@drawable/ic_palette_default"
android:background="@null" />
</FrameLayout>
<!-- ================= KHỐI 2: BOTTOM CONTROL PANEL (ĐIỀU KHIỂN CAMERA) ================= -->
<!-- Khối này gom gọn cả nút Zoom, Shutter và nút Xem ảnh, xóa bỏ các dải nền thừa -->
<ConstraintLayout
android:id="@+id/bottomControlPanel"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#000000"
app:layout_constraintTop_toBottomOf="@id/panelSelectionContainer"
app:layout_constraintBottom_toBottomOf="parent">
<!-- CỤM ZOOM THUẦN (Xóa bỏ dải nền cũ trong image_2afaba.png) -->
<LinearLayout
android:id="@+id/layoutZoomGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="12dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<TextView
android:id="@+id/tvZoom05"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0.5x"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:padding="8dp" />
<TextView
android:id="@+id/tvZoom1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1x"
android:textColor="#FF9800" <!-- Mc đnh màu cam cho 1x -->
android:textStyle="bold"
android:padding="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp" />
<TextView
android:id="@+id/tvZoom3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="3x"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:padding="8dp" />
</LinearLayout>
<!-- NÚT XEM ẢNH (Thay thế nút mũi tên Play cũ thành biểu tượng Image/Gallery) -->
<ImageButton
android:id="@+id/btnGalleryReview"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@null"
android:src="@drawable/ic_image_gallery" <!-- Đi sang icon bc nh hình vuông/thư vin -->
app:layout_constraintTop_toTopOf="@id/btnShutterCapture"
app:layout_constraintBottom_toBottomOf="@id/btnShutterCapture"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp" />
<!-- NÚT SHUTTER CHỤP ẢNH (Giữ cố định vị trí vàng ở giữa dưới cùng) -->
<ImageButton
android:id="@+id/btnShutterCapture"
android:layout_width="72dp"
android:layout_height="72dp"
android:background="@drawable/bg_shutter_orange_circle" <!-- Vin cam rut trng -->
android:layout_marginBottom="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/layoutZoomGroup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</ConstraintLayout>
```
---
## 📅 3. Các Đầu Việc Cần Cập Nhật Trong Mã Nguồn Kotlin
Sau khi cập nhật lại cây thư mục giao diện XML, bạn cần thực hiện ánh xạ lại ID trong `MainActivity.kt`:
1. **Thay đổi sự kiện Nút Xem Ảnh:** Đổi tên ánh xạ từ nút mũi tên Play cũ sang nút mới:
```kotlin
binding.btnGalleryReview.setOnClickListener {
// Thực hiện logic mở thư viện xem lại ảnh đã chụp
}
```
2. **Kiểm tra độ cao vùng chạm:** Vì chúng ta loại bỏ phần nền chứa các nút zoom cũ, `RecyclerView` của Timeline giờ đây có toàn bộ diện tích `80dp` rõ ràng. Các item hiển thị hiển thị trọn vẹn, không sợ bị các nút zoom bấm đè đứt đoạn nữa.
+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.
+214
View File
@@ -0,0 +1,214 @@
# 🎨 KẾ HOẠCH ĐIỀU CHỈNH: BẢNG SLIDERS FLOATING & HIỂN THỊ TRANSPARENT FRAME
Mục tiêu: Đưa bảng chỉnh sửa thành một khối mờ floating nằm gọn bên trong khung hình camera, cho phép cuộn dọc nội dung sliders. Đồng thời, cấu trúc lại hệ thống hiển thị để phần trong suốt (Transparent) của file Frame PNG lọt lòng đúng luồng camera.
---
## 📐 1. Cấu Trúc Lại Phân Tầng Giao Diện (`activity_main.xml`)
Để bảng điều khiển nằm lọt vào bên trong phần hình ảnh, chúng ta phải đưa nó vào làm con trực tiếp của `FrameLayout` quản lý camera (`cameraContainer`). Thứ tự khai báo trong XML sẽ quyết định tầng hiển thị (Z-index):
```
[FrameLayout: cameraContainer] (Tỉ lệ 3:4)
├── Lớp 1 (Đáy): PreviewView (Luồng camera gốc từ phần cứng)
├── Lớp 2 (Giữa): ImageView (Khung ảnh PNG trong suốt)
└── Lớp 3 (Trên cùng): CardView / ScrollView (Bảng Sliders mờ floating)
```
### 💻 Đoạn mã XML chỉnh sửa chi tiết:
```xml
<!-- KHU VỰC HÌNH ẢNH CAMERA CHÍNH (Tỉ lệ 3:4) -->
<FrameLayout
android:id="@+id/cameraContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="3:4"
app:layout_constraintTop_toBottomOf="@id/topBar">
<!-- LỚP 1: Kính ngắm Camera -->
<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- LỚP 2: Khung Frame PNG (Phần ruột trong suốt sẽ tự động lộ Camera ở Lớp 1 ra) -->
<ImageView
android:id="@+id/imgFrameOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter" />
<!-- LỚP 3: BẢNG SLIDERS FLOATING (Nằm lọt vào giữa hình ảnh như image_2be399.jpg) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardAdvancedSliders"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="24dp" <!-- To khong trng lt lòng xung quanh ging nh mu -->
app:cardCornerRadius="12dp"
app:cardElevation="8dp"
android:visibility="gone" <!-- Mặc định ẩn -->
app:strokeWidth="0dp"
android:backgroundTint="#A6000000"> <!-- Màu nền đen mờ tỉ lệ nén Alpha mịn -->
<!-- ScrollView cho phép cuộn lên xuống tự do để chỉnh tất cả các thông số -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:scrollbars="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Tiêu đề & Nút X (Đóng) -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/tvEditPresetName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Chỉnh sửa: Instax Nắng Chiều"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="15sp" />
<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"
app:tint="#FFFFFF" />
</RelativeLayout>
<!-- ================= DANH SÁCH CÁC CỤM SLIDERS ĐỂ CUỘN ================= -->
<!-- 1. VIBRANCE -->
<TextView
android:id="@+id/tvLabelVibrance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Vibrance (Bão hòa thông minh): 0.0"
android:textColor="#FFFFFF"
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" />
<!-- 2. CLARITY -->
<TextView
android:id="@+id/tvLabelClarity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Clarity (Độ rõ nét vùng trung tính): 0.0"
android:textColor="#FFFFFF"
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" />
<!-- 3. DEHAZE -->
<TextView
android:id="@+id/tvLabelDehaze"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Dehaze (Độ trong suốt / Sương mù): 0.0"
android:textColor="#FFFFFF"
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" />
<!-- Bổ sung thêm các Slider khác (Brightness, Saturation, Grain) vào đây để cuộn... -->
<!-- NÚT LƯU THÀNH PRESET MỚI -->
<Button
android:id="@+id/btnSaveCustomPreset"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:text="LƯU THÀNH PRESET MỚI"
android:textColor="#FFFFFF"
android:backgroundTint="#FF9800"
android:textStyle="bold"
android:insetTop="0dp"
android:insetBottom="0dp"
app:cornerRadius="4dp" />
</LinearLayout>
</ScrollView>
</com.google.android.material.card.MaterialCardView>
</FrameLayout>
```
---
## 💻 2. Kịch Bản Xử Lý Kỹ Thuật (Kotlin)
### 🔹 2.1. Hiện thực hóa việc hiện thị Frame suốt (Transparent Layer)
Để luồng camera hiện lên ở phần ruột trong suốt của khung hình:
1. Các file asset khung ảnh (ví dụ: `instaxframe.png`) thiết kế trên phần mềm đồ họa phải được đục lỗ rỗng (Alpha channel = 0) ở khu vực hiển thị ảnh chụp.
2. Trong Android, khi người dùng chọn mẫu từ Timeline, ta nạp trực tiếp bằng luồng luân chuyển nhẹ:
```kotlin
val bitmap = BitmapFactory.decodeStream(assets.open("frames/instaxframe.png"))
binding.imgFrameOverlay.setImageBitmap(bitmap)
```
Do cơ chế xếp chồng tầng của `FrameLayout`, luồng CameraX hoạt động ở `viewFinder` (Lớp 1) sẽ tự động xuyên thấu và hiển thị mượt mà qua các vùng trống của `imgFrameOverlay` (Lớp 2).
### 🔹 2.2. Logic cập nhật Text hiển thị chỉ số thời gian thực (Real-time Value Indicator)
Bám sát thiết kế hiển thị số ở nhãn text (ví dụ: `Vibrance (...): -0.23`), ta cập nhật chuỗi text liên tục khi con trỏ di chuyển:
```kotlin
binding.sliderVibrance.addOnChangeListener { slider, value, fromUser ->
// 1. Cập nhật chữ hiển thị giá trị số thực tế
binding.tvLabelVibrance.text = String.format("Vibrance (Bão hòa thông minh): %.2f", value)
// 2. Cập nhật Object tạm thời
editingPreset?.basic?.vibrance = value
// 3. Đẩy sang GPU render lập tức lên ViewFinder
applyLiveFilterToCamera(editingPreset)
}
```
---
## 📅 3. Các Đầu Việc Cần Triển Khai (Checklist)
* [ ] **Kiểm tra độ trong suốt Asset:** Đảm bảo tất cả các file khung ảnh trong thư mục `assets/frames/` là định dạng **PNG-24** hỗ trợ Transparent hoàn toàn.
* [ ] **Cập nhật hiệu ứng hiển thị Card:** Khi mở bảng sliders, áp dụng hiệu ứng phóng to nhẹ từ tâm (`ScaleAnimation`) thay vì trượt từ dưới lên để phù hợp hơn với kiểu giao diện hộp Floating Card cố định.
* [ ] **Tối ưu hóa ScrollView:** Cài đặt thuộc tính ngăn chặn xung đột vuốt (`Touch Interception`) giữa thanh cuộn của `ScrollView` và các con trỏ trượt của `Slider Mechanism` của Material Component, giúp việc kéo thông số không bị trượt nhầm làm cuộn cả bảng.
@@ -0,0 +1,78 @@
package com.photobooth.app
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
enum class EditAttribute {
BRIGHTNESS, CONTRAST, SATURATION, VIBRANCE, TEMPERATURE, TINT, // Nhóm 1: Cơ bản
CLARITY, DEHAZE, VIGNETTE_AMOUNT, // Nhóm 2: Nâng cao
FADED_BLACK_LEVEL, SHADOWS_TINT_R, SHADOWS_TINT_G, SHADOWS_TINT_B, // Nhóm 3: Tone Curve
GRAIN_AMOUNT, GRAIN_SIZE // Nhóm 4: Nhiễu hạt
}
data class AttributeNode(
val type: EditAttribute,
val displayName: String,
val iconResId: Int
)
class AttributeAdapter(
private val items: List<AttributeNode>,
private val onItemSelected: (AttributeNode) -> Unit
) : RecyclerView.Adapter<AttributeAdapter.ViewHolder>() {
var selectedPosition = 0
set(value) {
val old = field
field = value
notifyItemChanged(old)
notifyItemChanged(value)
}
fun updateSelection(selectedType: EditAttribute) {
val index = items.indexOfFirst { it.type == selectedType }
if (index != -1) {
selectedPosition = index
}
}
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val imgIcon: ImageView = view.findViewById(R.id.imgAttributeIcon)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_attribute_icon, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.imgIcon.setImageResource(item.iconResId)
// If selected, color is orange, else white
if (position == selectedPosition) {
holder.imgIcon.setColorFilter(Color.parseColor("#FF9800"))
} else {
// Keep original vector coloring if it is colored droplet, else white tint
if (item.type == EditAttribute.SHADOWS_TINT_R ||
item.type == EditAttribute.SHADOWS_TINT_G ||
item.type == EditAttribute.SHADOWS_TINT_B) {
holder.imgIcon.clearColorFilter()
} else {
holder.imgIcon.setColorFilter(Color.parseColor("#FFFFFF"))
}
}
holder.itemView.setOnClickListener {
selectedPosition = holder.adapterPosition
onItemSelected(item)
}
}
override fun getItemCount(): Int = items.size
}
@@ -0,0 +1,39 @@
package com.photobooth.app
data class ColorPreset(
val id: String,
var name: String,
val themeCategory: String,
val isEditable: Boolean,
val basic: BasicAdjustments,
val advanced: AdvancedEffects,
val toneCurve: ToneCurveEmulation,
val grain: FilmGrain
)
data class BasicAdjustments(
var brightness: Float, // -1.0f đến 1.0f
var contrast: Float, // -1.0f đến 1.0f
var saturation: Float, // -1.0f đến 1.0f
var vibrance: Float, // -1.0f đến 1.0f -> Bảo vệ màu da người
var temperature: Float, // -1.0f đến 1.0f -> Ấm (vàng) / Lạnh (xanh)
var tint: Float // -1.0f đến 1.0f -> Xanh lá / Hồng dâu
)
data class AdvancedEffects(
var clarity: Float, // -1.0f đến 1.0f -> Giảm để làm mịn thơ mộng
var dehaze: Float, // -1.0f đến 1.0f -> Giảm để tạo lớp sương hoài cổ
var vignetteAmount: Float // 0.0f đến 1.0f -> Bo tối 4 góc ảnh
)
data class ToneCurveEmulation(
var fadedBlackLevel: Float, // Nâng đáy màu đen thành xám sờn
var shadowsTintR: Float,
var shadowsTintG: Float,
var shadowsTintB: Float // Ám màu xanh rêu/xanh biển vào vùng tối
)
data class FilmGrain(
var grainAmount: Float, // Độ đậm hạt nhiễu
var grainSize: Float // Kích thước hạt nhiễu phim
)
@@ -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
@@ -31,11 +33,22 @@ class MainActivity : AppCompatActivity() {
private var imageCapture: ImageCapture? = null // Capture usecase
private var isCountingDown = false // Countdown state flag
// Frame UI selectors state
private lateinit var frameStacks: List<FrameStack>
private lateinit var stackAdapter: FrameStackAdapter
// Frame & Preset UI selectors state
private lateinit var allFrames: List<FrameItem>
private lateinit var colorPresets: List<ColorPreset>
private lateinit var frameItemAdapter: FrameItemAdapter
private lateinit var presetAdapter: PresetAdapter
private var isFrameModeOpen = false
private var isPresetModeOpen = false
private var currentSelectedFramePath: String? = null
private var currentSelectedPreset: ColorPreset? = null
private var editingPreset: ColorPreset? = null
private var currentAttribute: EditAttribute = EditAttribute.BRIGHTNESS
private lateinit var attributeAdapter: AttributeAdapter
private var startX = 0f
private var startVal = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -45,7 +58,7 @@ class MainActivity : AppCompatActivity() {
// Setup UI control listeners
setupCameraControls()
// Setup Frame selection lists
// Setup Frame/Preset selection lists
initFrameData()
setupRecyclerViews()
@@ -60,43 +73,164 @@ class MainActivity : AppCompatActivity() {
}
private fun initFrameData() {
val retroFrames = listOf(
FrameItem("DEFAULT", "", ""), // Default/No Frame
allFrames = listOf(
FrameItem("DEFAULT", "Mặc định", ""),
FrameItem("instax_mini_single", "Instax Mini", "frames/instax_mini_single.png"),
FrameItem("photobooth_4strip", "Photobooth 4-Strip", "frames/photobooth_4strip.png")
)
val vintageFrames = listOf(
FrameItem("DEFAULT", "", ""), // Default/No Frame
FrameItem("instax_mini_single_v", "Vintage Instax", "frames/instax_mini_single.png"),
FrameItem("photobooth_4strip_v", "Vintage 4-Strip", "frames/photobooth_4strip.png")
)
frameStacks = listOf(
FrameStack("retro", "Retro Theme", retroFrames),
FrameStack("vintage", "Vintage Theme", vintageFrames)
// Initialize default Color Presets
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)
)
)
// 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 temperature = extractJsonDoubleField(content, "temperature").toFloat()
val tint = extractJsonDoubleField(content, "tint").toFloat()
val clarity = extractJsonDoubleField(content, "clarity").toFloat()
val dehaze = extractJsonDoubleField(content, "dehaze").toFloat()
val vignetteAmount = extractJsonDoubleField(content, "vignette_amount").toFloat()
val fadedBlackLevel = extractJsonDoubleField(content, "faded_black_level").toFloat()
val shadowsTintR = extractJsonDoubleField(content, "shadows_tint_r").toFloat()
val shadowsTintG = extractJsonDoubleField(content, "shadows_tint_g").toFloat()
val shadowsTintB = extractJsonDoubleField(content, "shadows_tint_b").toFloat()
val grainAmount = extractJsonDoubleField(content, "grain_amount").toFloat()
val grainSize = extractJsonDoubleField(content, "grain_size").toFloat()
val preset = ColorPreset(
id = id,
name = name,
themeCategory = category,
isEditable = true,
basic = BasicAdjustments(brightness, contrast, saturation, vibrance, temperature, tint),
advanced = AdvancedEffects(clarity, dehaze, vignetteAmount),
toneCurve = ToneCurveEmulation(fadedBlackLevel, shadowsTintR, shadowsTintG, shadowsTintB),
grain = FilmGrain(grainAmount, grainSize)
)
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 Master Stack RecyclerView
binding.rvFrameStacks.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
stackAdapter = FrameStackAdapter(frameStacks) { stack, selectedPos ->
if (selectedPos == -1) {
// Collapse subframes
binding.rvSubFrames.visibility = android.view.View.GONE
} else {
// Expand and load stack subframes
binding.rvSubFrames.visibility = android.view.View.VISIBLE
frameItemAdapter.updateItems(stack.frames)
}
}
binding.rvFrameStacks.adapter = stackAdapter
// Setup Sub Frames RecyclerView
binding.rvSubFrames.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
frameItemAdapter = FrameItemAdapter(emptyList()) { frame ->
// Setup Shared Timeline RecyclerView
binding.rvHorizontalTimeline.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
frameItemAdapter = FrameItemAdapter(allFrames) { frame ->
setFrameOverlay(frame.imageFileName)
updateFrameModeIcon(frame)
}
presetAdapter = PresetAdapter(colorPresets, { preset ->
applyPresetFilter(preset)
updatePresetModeIcon(preset)
}, { preset ->
openAdvancedSliders(preset)
})
// Setup Attributes List and Ruler View for the editor panel
val attributeItems = listOf(
AttributeNode(EditAttribute.BRIGHTNESS, "Brightness (Độ sáng)", R.drawable.ic_attr_brightness),
AttributeNode(EditAttribute.CONTRAST, "Contrast (Tương phản)", R.drawable.ic_attr_contrast),
AttributeNode(EditAttribute.SATURATION, "Saturation (Bão hòa màu)", R.drawable.ic_attr_saturation),
AttributeNode(EditAttribute.VIBRANCE, "Vibrance (Bão hòa thông minh)", R.drawable.ic_attr_vibrance),
AttributeNode(EditAttribute.TEMPERATURE, "Temperature (Nhiệt màu)", R.drawable.ic_attr_temperature),
AttributeNode(EditAttribute.TINT, "Tint (Sắc độ hồng/xanh)", R.drawable.ic_attr_tint),
AttributeNode(EditAttribute.CLARITY, "Clarity (Độ rõ nét)", R.drawable.ic_attr_clarity),
AttributeNode(EditAttribute.DEHAZE, "Dehaze (Độ trong suốt / Sương mù)", R.drawable.ic_attr_dehaze),
AttributeNode(EditAttribute.VIGNETTE_AMOUNT, "Vignette Amount (Bo tối góc)", R.drawable.ic_attr_vignette),
AttributeNode(EditAttribute.FADED_BLACK_LEVEL, "Faded Black Level (Độ mờ vùng đen)", R.drawable.ic_attr_fade),
AttributeNode(EditAttribute.SHADOWS_TINT_R, "Shadows Tint R (Sắc Đỏ vùng tối)", R.drawable.ic_attr_tint_r),
AttributeNode(EditAttribute.SHADOWS_TINT_G, "Shadows Tint G (Sắc Lục vùng tối)", R.drawable.ic_attr_tint_g),
AttributeNode(EditAttribute.SHADOWS_TINT_B, "Shadows Tint B (Sắc Lam vùng tối)", R.drawable.ic_attr_tint_b),
AttributeNode(EditAttribute.GRAIN_AMOUNT, "Grain Amount (Mật độ hạt)", R.drawable.ic_attr_grain_amount),
AttributeNode(EditAttribute.GRAIN_SIZE, "Grain Size (Kích thước hạt)", R.drawable.ic_attr_grain_size)
)
attributeAdapter = AttributeAdapter(attributeItems) { node ->
onAttributeSelected(node)
}
binding.rvAttributeIcons.layoutManager = LinearLayoutManager(
this, LinearLayoutManager.HORIZONTAL, false
)
binding.rvAttributeIcons.adapter = attributeAdapter
binding.rulerView.onValueChangeListener = { valInt ->
binding.tvRulerValue.text = "$valInt"
setCurrentAttributeValue(valInt / 100f)
editingPreset?.let { applyPresetFilter(it) }
}
binding.rvSubFrames.adapter = frameItemAdapter
}
private fun setFrameOverlay(fileName: String) {
@@ -116,41 +250,549 @@ class MainActivity : AppCompatActivity() {
}
}
private fun applyPresetFilter(preset: ColorPreset) {
currentSelectedPreset = preset
if (preset.id == "DEFAULT") {
binding.imgFilterOverlay.visibility = android.view.View.GONE
binding.viewFinder.setLayerType(android.view.View.LAYER_TYPE_NONE, null)
} else {
binding.imgFilterOverlay.visibility = android.view.View.VISIBLE
val isBeingEdited = editingPreset != null
val useCalculation = isBeingEdited || (preset.id != "instax_faded_warm_01" && preset.id != "instax_bw_cool" && preset.id != "instax_cool_summer")
val filterColor = if (useCalculation) {
// 1. ALPHA (Độ đậm/Mờ của lớp filter): Được quyết định bởi Saturation, Vibrance và hiệu ứng nâng cao
// Quy đổi dải từ -1.0 -> 1.0 sang mức độ trong suốt hợp lý (từ 15 đến 140 trong hệ 255)
val baseAlpha = 60f
val saturationImpact = (preset.basic.saturation + preset.basic.vibrance) * 25f
val contrastImpact = preset.basic.contrast * 15f
val alpha = (baseAlpha + saturationImpact + contrastImpact).toInt().coerceIn(15, 140)
// 2. BASE COLOR (Màu nền mặc định ban đầu trước khi tinh chỉnh)
// Bắt đầu từ một màu trung tính hoặc lấy màu gốc của Theme nếu có
var baseR = 140f
var baseG = 130f
var baseB = 120f
// 3. BRIGHTNESS (Độ sáng): Bắt buộc phải cộng/trừ ĐỀU vào cả 3 kênh R, G, B để dịch chuyển Exposure
val brightnessOffset = preset.basic.brightness * 80f // Nhân hệ số để biên độ thay đổi từ -100 đến +100 rõ rệt
baseR += brightnessOffset
baseG += brightnessOffset
baseB += brightnessOffset
// 4. TEMPERATURE (Nhiệt màu): + là Ấm (Tăng Đỏ, Giảm Lam), - là Lạnh (Giảm Đỏ, Tăng Lam)
val tempOffset = preset.basic.temperature * 50f
baseR += tempOffset
baseB -= tempOffset
// 5. TINT (Sắc độ): + là Ám hồng (Tăng Đỏ & Lam, Giảm Lục), - là Ám xanh (Giảm Đỏ & Lam, Tăng Lục)
val tintOffset = preset.basic.tint * 40f
baseG -= tintOffset
baseR += (tintOffset * 0.5f)
baseB += (tintOffset * 0.5f)
// 6. TONE CURVE & SHADOWS TINT (Ám màu vùng tối đặc trưng của Instax)
baseR += (preset.toneCurve.shadowsTintR * 35f)
baseG += (preset.toneCurve.shadowsTintG * 35f)
baseB += (preset.toneCurve.shadowsTintB * 35f)
// Nâng đáy màu đen (Faded Black) tạo độ sờn ảnh
val fadeOffset = preset.toneCurve.fadedBlackLevel * 30f
baseR += fadeOffset
baseG += fadeOffset
baseB += fadeOffset
// 7. CLARITY & DEHAZE (Điều tiết thêm độ tương phản cục bộ của màu)
val advancedOffset = (preset.advanced.clarity - preset.advanced.dehaze) * 20f
baseR += advancedOffset
baseG += advancedOffset
baseB += advancedOffset
// 8. ÉP BIÊN ĐỘ (COERCE) để đảm bảo giá trị RGB luôn nằm trong giới hạn vật lý 0 -> 255
val r = baseR.toInt().coerceIn(0, 255)
val g = baseG.toInt().coerceIn(0, 255)
val b = baseB.toInt().coerceIn(0, 255)
android.graphics.Color.argb(alpha, r, g, b)
} else {
// Khối xử lý cho các mẫu mặc định cố định không sửa đổi
when (preset.id) {
"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.argb(0, 0, 0, 0)
}
}
binding.imgFilterOverlay.setBackgroundColor(filterColor)
applyColorMatrixToViewFinder(preset)
}
}
private fun applyColorMatrixToViewFinder(preset: ColorPreset) {
val finalMatrix = android.graphics.ColorMatrix()
// 1. Saturation & Vibrance (Sắc độ bão hòa màu thực tế)
val sat = (preset.basic.saturation + preset.basic.vibrance * 0.5f) + 1f
val satMatrix = android.graphics.ColorMatrix().apply {
setSaturation(sat.coerceIn(0f, 3f))
}
finalMatrix.postConcat(satMatrix)
// 2. Contrast (Tương phản thực tế)
val scale = preset.basic.contrast + 1f
val translate = (-0.5f * scale + 0.5f) * 255f
val contrastMatrix = android.graphics.ColorMatrix(floatArrayOf(
scale, 0f, 0f, 0f, translate,
0f, scale, 0f, 0f, translate,
0f, 0f, scale, 0f, translate,
0f, 0f, 0f, 1f, 0f
))
finalMatrix.postConcat(contrastMatrix)
// 3. Brightness (Độ sáng Exposure thực tế)
val brightnessOffset = preset.basic.brightness * 60f
val brightnessMatrix = android.graphics.ColorMatrix(floatArrayOf(
1f, 0f, 0f, 0f, brightnessOffset,
0f, 1f, 0f, 0f, brightnessOffset,
0f, 0f, 1f, 0f, brightnessOffset,
0f, 0f, 0f, 1f, 0f
))
finalMatrix.postConcat(brightnessMatrix)
// 4. Temperature (Nhiệt màu thực tế: Vàng ấm / Xanh lạnh)
val temp = preset.basic.temperature
val rScale = 1f + temp * 0.12f
val gScale = 1f + temp * 0.04f
val bScale = 1f - temp * 0.12f
val tempMatrix = android.graphics.ColorMatrix(floatArrayOf(
rScale, 0f, 0f, 0f, 0f,
0f, gScale, 0f, 0f, 0f,
0f, 0f, bScale, 0f, 0f,
0f, 0f, 0f, 1f, 0f
))
finalMatrix.postConcat(tempMatrix)
// 5. Tint (Sắc độ: Hồng dâu / Xanh lá)
val tint = preset.basic.tint
val rScaleTint = 1f + tint * 0.06f
val gScaleTint = 1f - tint * 0.12f
val bScaleTint = 1f + tint * 0.06f
val tintMatrix = android.graphics.ColorMatrix(floatArrayOf(
rScaleTint, 0f, 0f, 0f, 0f,
0f, gScaleTint, 0f, 0f, 0f,
0f, 0f, bScaleTint, 0f, 0f,
0f, 0f, 0f, 1f, 0f
))
finalMatrix.postConcat(tintMatrix)
val paint = android.graphics.Paint().apply {
colorFilter = android.graphics.ColorMatrixColorFilter(finalMatrix)
}
binding.viewFinder.setLayerType(android.view.View.LAYER_TYPE_HARDWARE, paint)
}
private fun updateFrameModeIcon(frame: FrameItem) {
if (frame.id == "DEFAULT" || frame.imageFileName.isEmpty()) {
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.btnMainFrame.setImageBitmap(bitmap)
} catch (e: Exception) {
binding.btnMainFrame.setImageResource(R.drawable.ic_default_frame_thumbnail)
}
}
}
private fun updatePresetModeIcon(preset: ColorPreset) {
if (preset.id == "DEFAULT") {
binding.btnMainPreset.setImageResource(R.drawable.ic_default_preset_thumbnail)
} else {
val colorHex = when (preset.id) {
"instax_faded_warm_01" -> "#FFF57C00"
"instax_bw_cool" -> "#FF808080"
"instax_cool_summer" -> "#FF00BCD4"
else -> "#FF9E9E9E"
}
val circleDrawable = android.graphics.drawable.GradientDrawable().apply {
shape = android.graphics.drawable.GradientDrawable.OVAL
setColor(android.graphics.Color.parseColor(colorHex))
}
binding.btnMainPreset.setImageDrawable(circleDrawable)
}
}
private fun setupCameraControls() {
// Toggle Switch Camera Button
// Mode Selectors
binding.btnMainFrame.setOnClickListener {
toggleFrameMode()
}
binding.btnMainPreset.setOnClickListener {
togglePresetMode()
}
// Top Bar
binding.btnSwitchCamera.setOnClickListener {
toggleCamera()
}
// Toggle Flash Button
binding.btnFlash.setOnClickListener {
toggleFlash()
}
// Toggle Timer Button
binding.btnTimer.setOnClickListener {
toggleTimer()
}
// Zoom 0.5x Button
binding.btnZoom05.setOnClickListener {
// Zoom Text selectors
binding.tvZoom05.setOnClickListener {
applyZoom(0.5f)
}
// Zoom 1x Button
binding.btnZoom10.setOnClickListener {
binding.tvZoom1.setOnClickListener {
applyZoom(1.0f)
}
// Zoom 3x Button
binding.btnZoom30.setOnClickListener {
binding.tvZoom3.setOnClickListener {
applyZoom(3.0f)
}
// Play Gallery Button
binding.btnGalleryReview.setOnClickListener {
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
type = "image/*"
flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
} catch (e: Exception) {
Toast.makeText(this, "Không thể mở Thư viện ảnh", Toast.LENGTH_SHORT).show()
}
}
// Shutter Button
binding.btnCapture.setOnClickListener {
binding.btnShutterCapture.setOnClickListener {
onCaptureClick()
}
// Camera viewfinder container swipe gesture listener to adjust parameters
binding.cameraContainer.setOnTouchListener { _, event ->
if (binding.layoutTimelineEditor.visibility != android.view.View.VISIBLE) {
return@setOnTouchListener false
}
when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> {
startX = event.x
startVal = binding.rulerView.value
}
android.view.MotionEvent.ACTION_MOVE -> {
val deltaX = event.x - startX
val sensitivity = 5f // pixels per unit value change
val deltaVal = (deltaX / sensitivity).toInt()
binding.rulerView.value = startVal + deltaVal
}
}
true
}
binding.btnEditorClose.setOnClickListener {
closeAdvancedSliders()
}
binding.btnEditorCheck.setOnClickListener {
editingPreset?.let { preset ->
showSavePresetDialog(preset)
}
}
}
private fun toggleFrameMode() {
if (isCountingDown) return
val wrapper = binding.buttonsWrapper
TransitionManager.beginDelayedTransition(wrapper)
if (!isFrameModeOpen) {
isFrameModeOpen = true
isPresetModeOpen = false
// Hide preset button
binding.btnMainPreset.visibility = android.view.View.GONE
// 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
// 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
// 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.rvHorizontalTimeline.adapter = presetAdapter
} else {
isPresetModeOpen = false
// 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 openAdvancedSliders(selectedPreset: ColorPreset) {
editingPreset = selectedPreset.copy(
basic = selectedPreset.basic.copy(),
advanced = selectedPreset.advanced.copy(),
toneCurve = selectedPreset.toneCurve.copy(),
grain = selectedPreset.grain.copy()
)
binding.layoutEditorTitleBar.visibility = android.view.View.VISIBLE
// Setup initial selected attribute: Brightness
val initialNode = AttributeNode(EditAttribute.BRIGHTNESS, "Brightness (Độ sáng)", R.drawable.ic_attr_brightness)
onAttributeSelected(initialNode)
binding.panelSelectionContainer.visibility = android.view.View.GONE
binding.bottomControlPanel.visibility = android.view.View.GONE
binding.layoutTimelineEditor.apply {
visibility = android.view.View.VISIBLE
alpha = 0f
animate()
.alpha(1f)
.setDuration(250)
.start()
}
}
private fun closeAdvancedSliders() {
binding.layoutTimelineEditor.animate()
.alpha(0f)
.setDuration(200)
.withEndAction {
binding.layoutTimelineEditor.visibility = android.view.View.GONE
binding.layoutEditorTitleBar.visibility = android.view.View.GONE
binding.panelSelectionContainer.visibility = android.view.View.VISIBLE
binding.bottomControlPanel.visibility = android.view.View.VISIBLE
editingPreset = null
currentSelectedPreset?.let { applyPresetFilter(it) }
}
.start()
}
private fun onAttributeSelected(selectedNode: AttributeNode) {
currentAttribute = selectedNode.type
binding.tvCurrentAttributeName.text = selectedNode.displayName
// Adjust Ruler limits based on attribute range
val isNonNegative = selectedNode.type == EditAttribute.VIGNETTE_AMOUNT ||
selectedNode.type == EditAttribute.FADED_BLACK_LEVEL ||
selectedNode.type == EditAttribute.SHADOWS_TINT_R ||
selectedNode.type == EditAttribute.SHADOWS_TINT_G ||
selectedNode.type == EditAttribute.SHADOWS_TINT_B ||
selectedNode.type == EditAttribute.GRAIN_AMOUNT ||
selectedNode.type == EditAttribute.GRAIN_SIZE
binding.rulerView.minVal = if (isNonNegative) 0 else -100
binding.rulerView.maxVal = 100
val currentVal = (getCurrentAttributeValue() * 100f).toInt()
binding.rulerView.value = currentVal
binding.tvRulerValue.text = "$currentVal"
attributeAdapter.updateSelection(selectedNode.type)
}
private fun getCurrentAttributeValue(): Float {
val preset = editingPreset ?: return 0f
return when (currentAttribute) {
EditAttribute.BRIGHTNESS -> preset.basic.brightness
EditAttribute.CONTRAST -> preset.basic.contrast
EditAttribute.SATURATION -> preset.basic.saturation
EditAttribute.VIBRANCE -> preset.basic.vibrance
EditAttribute.TEMPERATURE -> preset.basic.temperature
EditAttribute.TINT -> preset.basic.tint
EditAttribute.CLARITY -> preset.advanced.clarity
EditAttribute.DEHAZE -> preset.advanced.dehaze
EditAttribute.VIGNETTE_AMOUNT -> preset.advanced.vignetteAmount
EditAttribute.FADED_BLACK_LEVEL -> preset.toneCurve.fadedBlackLevel
EditAttribute.SHADOWS_TINT_R -> preset.toneCurve.shadowsTintR
EditAttribute.SHADOWS_TINT_G -> preset.toneCurve.shadowsTintG
EditAttribute.SHADOWS_TINT_B -> preset.toneCurve.shadowsTintB
EditAttribute.GRAIN_AMOUNT -> preset.grain.grainAmount
EditAttribute.GRAIN_SIZE -> preset.grain.grainSize
}
}
private fun setCurrentAttributeValue(value: Float) {
val preset = editingPreset ?: return
when (currentAttribute) {
EditAttribute.BRIGHTNESS -> preset.basic.brightness = value
EditAttribute.CONTRAST -> preset.basic.contrast = value
EditAttribute.SATURATION -> preset.basic.saturation = value
EditAttribute.VIBRANCE -> preset.basic.vibrance = value
EditAttribute.TEMPERATURE -> preset.basic.temperature = value
EditAttribute.TINT -> preset.basic.tint = value
EditAttribute.CLARITY -> preset.advanced.clarity = value
EditAttribute.DEHAZE -> preset.advanced.dehaze = value
EditAttribute.VIGNETTE_AMOUNT -> preset.advanced.vignetteAmount = value.coerceIn(0f, 1f)
EditAttribute.FADED_BLACK_LEVEL -> preset.toneCurve.fadedBlackLevel = value.coerceIn(0f, 1f)
EditAttribute.SHADOWS_TINT_R -> preset.toneCurve.shadowsTintR = value.coerceIn(0f, 1f)
EditAttribute.SHADOWS_TINT_G -> preset.toneCurve.shadowsTintG = value.coerceIn(0f, 1f)
EditAttribute.SHADOWS_TINT_B -> preset.toneCurve.shadowsTintB = value.coerceIn(0f, 1f)
EditAttribute.GRAIN_AMOUNT -> preset.grain.grainAmount = value.coerceIn(0f, 1f)
EditAttribute.GRAIN_SIZE -> preset.grain.grainSize = value.coerceIn(0f, 1f)
}
}
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,
tempPreset.basic.temperature,
tempPreset.basic.tint
),
advanced = AdvancedEffects(
tempPreset.advanced.clarity,
tempPreset.advanced.dehaze,
tempPreset.advanced.vignetteAmount
),
toneCurve = ToneCurveEmulation(
tempPreset.toneCurve.fadedBlackLevel,
tempPreset.toneCurve.shadowsTintR,
tempPreset.toneCurve.shadowsTintG,
tempPreset.toneCurve.shadowsTintB
),
grain = FilmGrain(
tempPreset.grain.grainAmount,
tempPreset.grain.grainSize
)
)
// Serialize to JSON format manually (incorporating all 14 parameters)
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": ${customPreset.basic.temperature},
"tint": ${customPreset.basic.tint}
},
"advanced_effects": {
"clarity": ${customPreset.advanced.clarity},
"dehaze": ${customPreset.advanced.dehaze},
"vignette_amount": ${customPreset.advanced.vignetteAmount}
},
"tone_curve_emulation": {
"faded_black_level": ${customPreset.toneCurve.fadedBlackLevel},
"shadows_tint_r": ${customPreset.toneCurve.shadowsTintR},
"shadows_tint_g": ${customPreset.toneCurve.shadowsTintG},
"shadows_tint_b": ${customPreset.toneCurve.shadowsTintB}
},
"film_grain": {
"grain_amount": ${customPreset.grain.grainAmount},
"grain_size": ${customPreset.grain.grainSize}
}
}
""".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()
closeAdvancedSliders()
} catch (e: Exception) {
Toast.makeText(this, "Không thể lưu Preset file", Toast.LENGTH_SHORT).show()
}
}
private fun startCamera() {
@@ -196,15 +838,15 @@ class MainActivity : AppCompatActivity() {
} else {
CameraSelector.DEFAULT_FRONT_CAMERA
}
binding.btnSwitchCamera.text = if (lensFacing == CameraSelector.DEFAULT_FRONT_CAMERA) {
getString(R.string.camera_front)
} else {
getString(R.string.camera_back)
}
// Collapse selections and reset overlay for clean transition
stackAdapter.clearSelection()
binding.rvSubFrames.visibility = android.view.View.GONE
// Reset Selector UI states
if (isFrameModeOpen) toggleFrameMode()
if (isPresetModeOpen) togglePresetMode()
setFrameOverlay("")
applyPresetFilter(colorPresets[0])
updateFrameModeIcon(FrameItem("DEFAULT", "", ""))
updatePresetModeIcon(colorPresets[0])
startCamera()
}
@@ -214,16 +856,19 @@ class MainActivity : AppCompatActivity() {
ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO
else -> ImageCapture.FLASH_MODE_OFF
}
binding.btnFlash.text = when (flashMode) {
ImageCapture.FLASH_MODE_ON -> getString(R.string.flash_on)
ImageCapture.FLASH_MODE_AUTO -> getString(R.string.flash_auto)
else -> getString(R.string.flash_off)
}
updateFlashIconState(flashMode)
imageCapture?.flashMode = flashMode
}
private fun updateFlashIconState(flashMode: Int) {
val color = if (flashMode == ImageCapture.FLASH_MODE_OFF) {
ContextCompat.getColor(this, R.color.theme_white)
} else {
ContextCompat.getColor(this, R.color.theme_primary)
}
binding.btnFlash.imageTintList = android.content.res.ColorStateList.valueOf(color)
}
private fun toggleTimer() {
if (isCountingDown) return
timerSeconds = when (timerSeconds) {
@@ -232,11 +877,16 @@ class MainActivity : AppCompatActivity() {
5 -> 10
else -> 0
}
updateTimerIconState(timerSeconds)
}
binding.btnTimer.text = when (timerSeconds) {
0 -> getString(R.string.timer_off)
else -> getString(R.string.timer_seconds, timerSeconds)
private fun updateTimerIconState(timerSecs: Int) {
val color = if (timerSecs == 0) {
ContextCompat.getColor(this, R.color.theme_white)
} else {
ContextCompat.getColor(this, R.color.theme_primary)
}
binding.btnTimer.imageTintList = android.content.res.ColorStateList.valueOf(color)
}
private fun applyZoom(ratio: Float) {
@@ -259,15 +909,21 @@ class MainActivity : AppCompatActivity() {
}
private fun updateZoomButtonsUI(ratio: Float) {
binding.btnZoom05.backgroundTintList = ContextCompat.getColorStateList(
this, if (ratio == 0.5f) R.color.theme_primary else R.color.theme_item_card_bg
)
binding.btnZoom10.backgroundTintList = ContextCompat.getColorStateList(
this, if (ratio == 1.0f) R.color.theme_primary else R.color.theme_item_card_bg
)
binding.btnZoom30.backgroundTintList = ContextCompat.getColorStateList(
this, if (ratio == 3.0f) R.color.theme_primary else R.color.theme_item_card_bg
)
val selectedTv = when (ratio) {
0.5f -> binding.tvZoom05
1.0f -> binding.tvZoom1
3.0f -> binding.tvZoom3
else -> binding.tvZoom1
}
updateZoomTextState(selectedTv)
}
private fun updateZoomTextState(selectedZoom: android.widget.TextView) {
binding.tvZoom05.setTextColor(ContextCompat.getColor(this, R.color.theme_white))
binding.tvZoom1.setTextColor(ContextCompat.getColor(this, R.color.theme_white))
binding.tvZoom3.setTextColor(ContextCompat.getColor(this, R.color.theme_white))
selectedZoom.setTextColor(ContextCompat.getColor(this, R.color.theme_primary))
}
private fun onCaptureClick() {
@@ -0,0 +1,128 @@
package com.photobooth.app
import android.graphics.Color
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
import com.google.android.material.card.MaterialCardView
class PresetAdapter(
private var presets: List<ColorPreset>,
private val onPresetSelected: (ColorPreset) -> Unit,
private val onEditClicked: (ColorPreset) -> Unit
) : RecyclerView.Adapter<PresetAdapter.ViewHolder>() {
private var selectedPosition = 0
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)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_preset, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val preset = presets[position]
holder.txtPresetName.text = preset.name
// Draw preview circle or block icon
if (preset.id == "DEFAULT") {
holder.viewPresetColor.setBackgroundResource(R.drawable.ic_block_white)
holder.txtPresetName.text = holder.itemView.context.getString(R.string.default_frame)
} else {
val colorHex = when (preset.id) {
"instax_faded_warm_01" -> "#FFF57C00"
"instax_bw_cool" -> "#FF808080"
"instax_cool_summer" -> "#FF00BCD4"
else -> "#FF9E9E9E"
}
val circleDrawable = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor(colorHex))
}
holder.viewPresetColor.background = circleDrawable
}
// 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
} else {
holder.cardPreset.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_transparent)
holder.cardPreset.strokeWidth = 0
}
// Setup custom 1-second long press detector with touch slop cancellation
val handler = android.os.Handler(android.os.Looper.getMainLooper())
var isLongPressTriggered = false
var startX = 0f
var startY = 0f
val touchSlop = 15f // pixels
val longPressRunnable = Runnable {
if (preset.isEditable) {
isLongPressTriggered = true
holder.itemView.performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS)
onEditClicked(preset)
}
}
holder.itemView.setOnTouchListener { view, event ->
when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> {
isLongPressTriggered = false
startX = event.x
startY = event.y
handler.postDelayed(longPressRunnable, 800)
}
android.view.MotionEvent.ACTION_MOVE -> {
if (Math.abs(event.x - startX) > touchSlop || Math.abs(event.y - startY) > touchSlop) {
handler.removeCallbacks(longPressRunnable)
}
}
android.view.MotionEvent.ACTION_UP -> {
handler.removeCallbacks(longPressRunnable)
if (!isLongPressTriggered) {
val previousSelected = selectedPosition
selectedPosition = holder.adapterPosition
if (selectedPosition != RecyclerView.NO_POSITION) {
notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition)
onPresetSelected(preset)
view.performClick()
}
}
}
android.view.MotionEvent.ACTION_CANCEL -> {
handler.removeCallbacks(longPressRunnable)
}
}
true
}
}
override fun getItemCount(): Int = presets.size
fun updatePresets(newPresets: List<ColorPreset>) {
this.presets = newPresets
notifyDataSetChanged()
}
fun resetSelection() {
val previousSelected = selectedPosition
selectedPosition = 0
notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition)
}
}
@@ -0,0 +1,114 @@
package com.photobooth.app
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
class RulerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
var value: Int = 0
set(newValue) {
val clamped = newValue.coerceIn(minVal, maxVal)
if (field != clamped) {
field = clamped
invalidate()
onValueChangeListener?.invoke(clamped)
}
}
var minVal: Int = -100
set(newValue) {
field = newValue
invalidate()
}
var maxVal: Int = 100
set(newValue) {
field = newValue
invalidate()
}
var onValueChangeListener: ((Int) -> Unit)? = null
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var lastX = 0f
private var isDragging = false
private val density = resources.displayMetrics.density
private fun dp(value: Float) = value * density
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val w = width.toFloat()
val h = height.toFloat()
val cx = w / 2f
// Draw ticks
// One tick mark every tickInterval value units.
// The spacing in pixels between tickInterval units is tickSpacingPx.
val tickInterval = 2
val tickSpacingPx = dp(8f)
// Draw ticks within visible area
val unitsPerPixel = tickInterval / tickSpacingPx
val minVisibleUnit = (value - cx * unitsPerPixel).toInt() - 5
val maxVisibleUnit = (value + cx * unitsPerPixel).toInt() + 5
for (u in minVisibleUnit..maxVisibleUnit) {
if (u < minVal || u > maxVal) continue
if (u % tickInterval != 0) continue
val x = cx + (u - value) * (tickSpacingPx / tickInterval)
if (x < 0 || x > w) continue
val isMajor = u % 10 == 0
val isMedium = u % 5 == 0 && !isMajor
val tickHeight = when {
isMajor -> dp(20f)
isMedium -> dp(14f)
else -> dp(8f)
}
paint.color = if (isMajor) Color.WHITE else Color.GRAY
paint.strokeWidth = if (isMajor) dp(1.5f) else dp(1.0f)
// Draw line from bottom upwards
canvas.drawLine(x, h, x, h - tickHeight, paint)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
parent.requestDisallowInterceptTouchEvent(true)
when (event.action) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
isDragging = true
}
MotionEvent.ACTION_MOVE -> {
if (isDragging) {
val deltaX = event.x - lastX
lastX = event.x
// Move the value proportionally to translation
val tickSpacingPx = dp(8f)
val tickInterval = 2
val pixelsPerUnit = tickSpacingPx / tickInterval
val deltaVal = -(deltaX / pixelsPerUnit)
value = (value + deltaVal).toInt().coerceIn(minVal, maxVal)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
isDragging = false
}
}
return true
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#40FF9800">
<item>
<shape android:shape="oval">
<solid android:color="#FFFFFF" />
<stroke
android:width="4dp"
android:color="#FF9800" />
</shape>
</item>
</ripple>
@@ -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="#FF9800"
android:pathData="M12,17l-6,-6h12z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M20,8.69V4h-4.69L12,0.69 8.69,4H4v4.69L0.69,12 4,15.31V20h4.69L12,23.31 15.31,20H20v-4.69L23.31,12 20,8.69zM12,18c-3.31,0-6-2.69-6-6s2.69-6 6-6 6,2.69 6,6-2.69,6-6,6z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10-4.48 10-10S17.52,2 12,2zm0,18c-4.41,0-8-3.59-8-8s3.59-8 8-8 8,3.59 8,8-3.59,8-8,8zm0-13c-2.76,0-5,2.24-5,5s2.24,5 5,5 5-2.24 5-5-2.24-5-5-5zm0,8c-1.66,0-3-1.34-3-3s1.34-3 3-3 3,1.34 3,3-1.34,3-3,3z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,22c5.52,0 10-4.48 10-10S17.52,2 12,2 2,6.48 2,12s4.48,10 10,10zM12,4c4.42,0 8,3.58 8,8s-3.58,8-8,8V4z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M2,4h20v2H2zm0,5h20v2H2zm0,5h20v2H2zm0,5h20v2H2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2-0.9 2-2V5C21,3.9 20.1,3 19,3zm-2,10H7v-2h10v2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,5h2v2H3zm4,0h2v2H7zm4,0h2v2H11zm4,0h2v2H15zm4,0h2v2H19zM3,11h2v2H3zm4,0h2v2H7zm4,0h2v2H11zm4,0h2v2H15zm4,0h2v2H19zM3,17h2v2H3zm4,0h2v2H7zm4,0h2v2H11zm4,0h2v2H15zm4,0h2v2H19z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M6,12a2,2 0 1,0 4,0a2,2 0 1,0 -4,0M14,12a4,4 0 1,0 8,0a4,4 0 1,0 -8,0" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10-4.48 10-10S17.52,2 12,2zm0,18c-4.41,0-8-3.59-8-8s3.59-8 8-8 8,3.59 8,8-3.59,8-8,8z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M15,13V5c0-1.66-1.34-3-3-3S9,3.34 9,5v8c-1.21,0.91-2,2.37-2,4 0,2.76 2.24,5 5,5s5-2.24 5-5c0-1.63-0.79-3.09-2-4zM12,20c-1.66,0-3-1.34-3-3 0-1.09 0.58-2.05 1.5-2.58V5c0-0.28 0.22-0.5 0.5-0.5s0.5,0.22 0.5,0.5v9.42c0.92,0.53 1.5,1.49 1.5,2.58 0,1.66-1.34,3-3,3z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2.69C12,2.69 5,10 5,14c0,3.86 3.14,7 7,7s7-3.14 7-7c0-4-7-11.31-7-11.31zM12,19c-2.76,0-5-2.24-5-5 0-2.5 3.57-7.44 5-9.25 1.43,1.8 5,6.73 5,9.25 0,2.76-2.24,5-5,5z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#40C4FF"
android:pathData="M12,2.69C12,2.69 5,10 5,14c0,3.86 3.14,7 7,7s7-3.14 7-7c0-4-7-11.31-7-11.31zM12,19c-2.76,0-5-2.24-5-5 0-2.5 3.57-7.44 5-9.25 1.43,1.8 5,6.73 5,9.25 0,2.76-2.24,5-5,5z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#69F0AE"
android:pathData="M12,2.69C12,2.69 5,10 5,14c0,3.86 3.14,7 7,7s7-3.14 7-7c0-4-7-11.31-7-11.31zM12,19c-2.76,0-5-2.24-5-5 0-2.5 3.57-7.44 5-9.25 1.43,1.8 5,6.73 5,9.25 0,2.76-2.24,5-5,5z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF5252"
android:pathData="M12,2.69C12,2.69 5,10 5,14c0,3.86 3.14,7 7,7s7-3.14 7-7c0-4-7-11.31-7-11.31zM12,19c-2.76,0-5-2.24-5-5 0-2.5 3.57-7.44 5-9.25 1.43,1.8 5,6.73 5,9.25 0,2.76-2.24,5-5,5z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,7.5L14,12l4.5,2-4.5,2-2,4.5-2-4.5-4.5-2 4.5-2 2-4.5zM19,3l1,2.2L22.2,6l-2.2,1L19,9.2 18,7l-2.2-1 2.2-1L19,3z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M21,3H3C1.9,3 1,3.9 1,5v14c0,1.1 0.9,2 2,2h18c1.1,0 2-0.9 2-2V5C23,3.9 22.1,3 21,3zM12,18c-3.31,0-6-2.69-6-6s2.69-6 6-6s6,2.69 6,6S15.31,18 12,18z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M20,8.69V4h-4.69L12,0.69 8.69,4H4v4.69L0.69,12 4,15.31V20h4.69L12,23.31 15.31,20H20v-4.69L23.31,12 20,8.69zM12,18c-3.31,0-6-2.69-6-6s2.69-6 6-6 6,2.69 6,6-2.69,6-6,6z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41-1.41z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/theme_white"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,22c5.52,0 10-4.48 10-10S17.52,2 12,2 2,6.48 2,12s4.48,10 10,10zM12,4c4.42,0 8,3.58 8,8s-3.58,8-8,8V4z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/theme_white"
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zm0,16H5V5h14v14zm-4,-4H9v-4h6v4z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/theme_white"
android:pathData="M12,2C6.49,2 2,6.49 2,12s4.49,10 10,10c1.38,0 2.5,-1.12 2.5,-2.5 0,-0.61 -0.22,-1.19 -0.62,-1.64 -0.38,-0.43 -0.59,-0.99 -0.59,-1.56 0,-1.38 1.12,-2.5 2.5,-2.5H19c1.66,0 3,-1.34 3,-3 0,-4.97 -4.49,-9 -10,-9zm-5.5,9c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,8 6.5,8 8,8.67 8,9.5 7.33,11 6.5,11zm3,-4C8.67,7 8,6.33 8,5.5S8.67,4 9.5,4 11,4.67 11,5.5 10.33,7 9.5,7zm5,0c-0.83,0 -1.5,-0.67 -1.5,-1.5S13.67,4 14.5,4 16,4.67 16,5.5 15.33,7 14.5,7zm3.5,4c-0.83,0 -1.5,-0.67 -1.5,-1.5S17.17,8 18,8s1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@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>
@@ -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="M7,2v11h3v9l7-12h-4l4-8z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/theme_white"
android:pathData="M20,4h-3.17L15,2H9L7.17,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.1,4 20,4zm0,14H4V6h4.52l1.83,-2h3.3L15.48,6H20v12zm-8,-9c-2.21,0 -4,1.79 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2H16c0,-2.21 -1.79,-4 -4,-4zm0,6c2.21,0 4,-1.79 4,-4h-2c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2H8c0,2.21 1.79,4 4,4z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/theme_white"
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zm0,16H5V5h14v14zm-5.04,-6.71l-2.75,3.54 -1.96,-2.36L6.5,17h11l-3.54,-4.71z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/theme_white"
android:pathData="M8,5v14l11,-7z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10-4.48 10-10S17.52,2 12,2zm0,18c-4.41,0-8-3.59-8-8s3.59-8 8-8 8,3.59 8,8-3.59,8-8,8z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M15,13V5c0-1.66-1.34-3-3-3S9,3.34 9,5v8c-1.21,0.91-2,2.37-2,4 0,2.76 2.24,5 5,5s5-2.24 5-5c0-1.63-0.79-3.09-2-4zM12,20c-1.66,0-3-1.34-3-3 0-1.09 0.58-2.05 1.5-2.58V5c0-0.28 0.22-0.5 0.5-0.5s0.5,0.22 0.5,0.5v9.42c0.92,0.53 1.5,1.49 1.5,2.58 0,1.66-1.34,3-3,3z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/theme_white"
android:pathData="M15,1L9,1v2h6L15,1zm-3,5c-4.42,0 -8,3.58 -8,8s3.58,8 8,8 8,-3.58 8,-8 -3.58,-8 -8,-8zm0,14c-3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6 6,2.69 6,6 -2.69,6 -6,6zm1,-9h-2v5h4v-2h-2L13,11z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,7.5L14,12l4.5,2-4.5,2-2,4.5-2-4.5-4.5-2 4.5-2 2-4.5zM19,3l1,2.2L22.2,6l-2.2,1L19,9.2 18,7l-2.2-1 2.2-1L19,3z" />
</vector>
@@ -16,54 +16,75 @@
android:paddingHorizontal="16dp"
app:layout_constraintTop_toTopOf="parent">
<Button
<ImageButton
android:id="@+id/btnFlash"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@color/theme_transparent"
android:src="@drawable/ic_flash"
app:tint="@color/theme_white"
android:contentDescription="Flash Toggle" />
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:backgroundTint="@color/theme_item_card_bg"
android:textColor="@color/theme_white"
android:text="@string/flash_off"
android:textSize="12sp"
android:insetTop="0dp"
android:insetBottom="0dp" />
android:layout_weight="1" />
<Button
<ImageButton
android:id="@+id/btnTimer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:backgroundTint="@color/theme_item_card_bg"
android:textColor="@color/theme_white"
android:text="@string/timer_off"
android:textSize="12sp"
android:insetTop="0dp"
android:insetBottom="0dp" />
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@color/theme_transparent"
android:src="@drawable/ic_timer"
app:tint="@color/theme_white"
android:contentDescription="Timer Toggle" />
<Button
android:id="@+id/btnSwitchCamera"
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:backgroundTint="@color/theme_item_card_bg"
android:textColor="@color/theme_white"
android:text="@string/camera_front"
android:textSize="12sp"
android:insetTop="0dp"
android:insetBottom="0dp" />
android:layout_weight="1" />
<ImageButton
android:id="@+id/btnSwitchCamera"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@color/theme_transparent"
android:src="@drawable/ic_flip_camera"
app:tint="@color/theme_white"
android:contentDescription="Switch Camera" />
</LinearLayout>
<!-- ================= 2. KHUNG FRAME CHÍNH (CAMERA + OVERLAY) ================= -->
<!-- DẢI ĐEN PHÍA TRÊN ẢNH CAMERA (Như mô tả trong image.png) -->
<RelativeLayout
android:id="@+id/layoutEditorTitleBar"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="#000000"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/topBar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<!-- Văn bản hiển thị động tên nút đang được chọn -->
<TextView
android:id="@+id/tvCurrentAttributeName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="Brightness (Độ sáng)"
android:textColor="#FF9800"
android:textSize="14sp"
android:textStyle="bold" />
</RelativeLayout>
<!-- ================= 2. KHUNG FRAME CHÍNH (CAMERA + FILTER + FRAME OVERLAY) ================= -->
<FrameLayout
android:id="@+id/cameraContainer"
android:layout_width="match_parent"
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
@@ -71,6 +92,15 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Lớp đè lọc màu (Filter Overlay): Hiển thị màu Preset cổ điển -->
<ImageView
android:id="@+id/imgFilterOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:visibility="gone"
android:contentDescription="Filter Overlay" />
<!-- Lớp đè (Overlay): Hiển thị Khung hình PNG do người dùng chọn -->
<ImageView
android:id="@+id/imgFrameOverlay"
@@ -94,106 +124,237 @@
android:shadowRadius="10"
android:visibility="gone"
android:text="3" />
</FrameLayout>
<!-- ================= 3. TIMELINE DANH SÁCH STACK FRAMES ================= -->
<LinearLayout
android:id="@+id/timelineContainer"
<!-- ================= KHỐI 1: TIMELINE FRAMES & PRESETS ================= -->
<!-- Đặt ngay phía dưới Camera và không bị ai đè lên nữa -->
<FrameLayout
android:id="@+id/panelSelectionContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_height="96dp"
android:background="#000000"
app:layout_constraintTop_toBottomOf="@id/cameraContainer"
app:layout_constraintBottom_toTopOf="@id/zoomControls">
app:layout_constraintBottom_toTopOf="@id/bottomControlPanel">
<!-- TẦNG BỔ SUNG: RecyclerView hiển thị các mẫu khung con bên trong Stack -->
<!-- Danh sách cuộn ngang -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSubFrames"
android:id="@+id/rvHorizontalTimeline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:orientation="horizontal"
android:padding="6dp"
android:background="@color/theme_sub_bar_bg"
android:clipToPadding="false"
android:paddingStart="75dp"
android:paddingEnd="16dp"
android:visibility="gone" />
<!-- TẦNG GỐC: Danh sách các Stack chủ đề lớn -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvFrameStacks"
<!-- 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">
<!-- 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" />
<!-- 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>
<!-- ================= KHỐI 2: BOTTOM CONTROL PANEL (ĐIỀU KHIỂN CAMERA) ================= -->
<!-- Khối này gom gọn cả nút Zoom, Shutter và nút Xem ảnh, xóa bỏ các dải nền thừa -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bottomControlPanel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#000000"
app:layout_constraintTop_toBottomOf="@id/panelSelectionContainer"
app:layout_constraintBottom_toBottomOf="parent">
<!-- CỤM ZOOM THUẦN (Xóa bỏ dải nền cũ) -->
<LinearLayout
android:id="@+id/layoutZoomGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp" />
</LinearLayout>
android:layout_marginTop="12dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<!-- ================= 4. CỤM NÚT ĐIỀU KHIỂN ZOOM ================= -->
<LinearLayout
android:id="@+id/zoomControls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@color/theme_sub_bar_bg"
android:padding="4dp"
app:layout_constraintTop_toBottomOf="@id/timelineContainer"
app:layout_constraintBottom_toTopOf="@id/controlPanel"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<TextView
android:id="@+id/tvZoom05"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0.5x"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:padding="8dp" />
<Button
android:id="@+id/btnZoom05"
android:layout_width="50dp"
android:layout_height="36dp"
android:layout_marginHorizontal="2dp"
android:backgroundTint="@color/theme_item_card_bg"
android:textColor="@color/theme_white"
android:text="@string/zoom_05x"
android:textSize="10sp"
android:padding="0dp"
android:insetTop="0dp"
android:insetBottom="0dp" />
<TextView
android:id="@+id/tvZoom1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1x"
android:textColor="@color/theme_primary"
android:textStyle="bold"
android:padding="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp" />
<Button
android:id="@+id/btnZoom10"
android:layout_width="50dp"
android:layout_height="36dp"
android:layout_marginHorizontal="2dp"
android:backgroundTint="@color/theme_primary"
android:textColor="@color/theme_white"
android:text="@string/zoom_1x"
android:textSize="10sp"
android:padding="0dp"
android:insetTop="0dp"
android:insetBottom="0dp" />
<Button
android:id="@+id/btnZoom30"
android:layout_width="50dp"
android:layout_height="36dp"
android:layout_marginHorizontal="2dp"
android:backgroundTint="@color/theme_item_card_bg"
android:textColor="@color/theme_white"
android:text="@string/zoom_3x"
android:textSize="10sp"
android:padding="0dp"
android:insetTop="0dp"
android:insetBottom="0dp" />
</LinearLayout>
<!-- ================= 5. CONTROL PANEL CHỤP ẢNH ================= -->
<RelativeLayout
android:id="@+id/controlPanel"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/theme_panel_dark"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/zoomControls">
<TextView
android:id="@+id/tvZoom3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="3x"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:padding="8dp" />
</LinearLayout>
<!-- NÚT XEM ẢNH (Biểu tượng Image/Gallery) -->
<ImageButton
android:id="@+id/btnCapture"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_centerInParent="true"
android:background="@drawable/bg_capture_button"
android:src="@drawable/ic_camera_white"
android:contentDescription="@string/btn_capture_desc" />
</RelativeLayout>
android:id="@+id/btnGalleryReview"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@null"
android:src="@drawable/ic_image_gallery"
app:layout_constraintTop_toTopOf="@id/btnShutterCapture"
app:layout_constraintBottom_toBottomOf="@id/btnShutterCapture"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"
android:contentDescription="Review Gallery" />
<!-- NÚT SHUTTER CHỤP ẢNH (Viền cam ruột trắng) -->
<ImageButton
android:id="@+id/btnShutterCapture"
android:layout_width="72dp"
android:layout_height="72dp"
android:background="@drawable/bg_shutter_orange_circle"
android:layout_marginBottom="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/layoutZoomGroup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:contentDescription="Shutter Button" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- KHU VỰC ĐIỀU KHIỂN CHỈNH SỬA ẢNH (Thay thế Control Panel khi nhấn giữ preset) -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layoutTimelineEditor"
android:layout_width="match_parent"
android:layout_height="180dp"
android:background="#000000"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent">
<!-- TẦNG 1: THANH TRƯỢT THƯỚC ĐO ĐỘC ĐÁO (RULER SLIDER) -->
<FrameLayout
android:id="@+id/rulerContainer"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toTopOf="parent">
<com.photobooth.app.RulerView
android:id="@+id/rulerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Kim chỉ số 0 màu cam chính giữa cố định -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center">
<TextView
android:id="@+id/tvRulerValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="#FF9800"
android:textSize="12sp"
android:textStyle="bold"
android:layout_gravity="center_horizontal"/>
<ImageView
android:layout_width="12dp"
android:layout_height="12dp"
android:src="@drawable/ic_arrow_down_orange"
android:layout_gravity="center_horizontal"
android:contentDescription="Indicator" />
</LinearLayout>
</FrameLayout>
<!-- TẦNG 2: PHẦN HIỂN THỊ ICON CÁC THUỘC TÍNH (HORIZONTAL LIST) -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvAttributeIcons"
android:layout_width="match_parent"
android:layout_height="50dp"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:clipToPadding="false"
app:layout_constraintTop_toBottomOf="@id/rulerContainer"
app:layout_constraintBottom_toTopOf="@id/layoutEditorActionButtons" />
<!-- TẦNG 3: THANH THAO TÁC HOÀN THÀNH (HỦY / ĐỒNG Ý) -->
<RelativeLayout
android:id="@+id/layoutEditorActionButtons"
android:layout_width="match_parent"
android:layout_height="48dp"
android:paddingStart="20dp"
android:paddingEnd="20dp"
app:layout_constraintBottom_toBottomOf="parent">
<!-- Nút X (Hủy bỏ các thông số vừa chỉnh) -->
<ImageButton
android:id="@+id/btnEditorClose"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@null"
android:src="@drawable/ic_close_white"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:contentDescription="Close" />
<!-- Nút Dấu Tích (Đồng ý để lưu preset) -->
<ImageButton
android:id="@+id/btnEditorCheck"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@null"
android:src="@drawable/ic_check_white"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:contentDescription="Save" />
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -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>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="64dp"
android:layout_height="50dp">
<ImageView
android:id="@+id/imgAttributeIcon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Attribute Icon" />
</FrameLayout>
@@ -2,8 +2,8 @@
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cardSubFrame"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_width="68dp"
android:layout_height="68dp"
android:layout_margin="4dp"
app:cardCornerRadius="6dp"
app:cardElevation="1dp"
@@ -19,8 +19,8 @@
<ImageView
android:id="@+id/imgFrameThumb"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitCenter"
android:contentDescription="Frame Thumb" />
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardPreset"
android:layout_width="68dp"
android:layout_height="68dp"
android:layout_margin="4dp"
app:cardCornerRadius="6dp"
app:cardElevation="1dp"
app:strokeWidth="0dp"
app:cardBackgroundColor="@color/theme_item_card_bg">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
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>
</FrameLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB