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

This commit is contained in:
2026-07-05 17:10:03 +07:00
parent df1192dac4
commit edd4c2946d
13 changed files with 837 additions and 147 deletions
+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`.
@@ -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
)
@@ -31,11 +31,16 @@ 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
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -45,7 +50,7 @@ class MainActivity : AppCompatActivity() {
// Setup UI control listeners
setupCameraControls()
// Setup Frame selection lists
// Setup Frame/Preset selection lists
initFrameData()
setupRecyclerViews()
@@ -60,43 +65,70 @@ 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 Color Presets matching 6_PRESETS_COLOR.md
colorPresets = 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)
)
)
}
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.rvSharedTimeline.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
frameItemAdapter = FrameItemAdapter(allFrames) { frame ->
setFrameOverlay(frame.imageFileName)
updateFrameModeIcon(frame)
}
presetAdapter = PresetAdapter(colorPresets) { preset ->
applyPresetFilter(preset)
updatePresetModeIcon(preset)
}
binding.rvSubFrames.adapter = frameItemAdapter
}
private fun setFrameOverlay(fileName: String) {
@@ -116,43 +148,144 @@ class MainActivity : AppCompatActivity() {
}
}
private fun applyPresetFilter(preset: ColorPreset) {
currentSelectedPreset = preset
if (preset.id == "DEFAULT") {
binding.imgFilterOverlay.visibility = android.view.View.GONE
} else {
binding.imgFilterOverlay.visibility = android.view.View.VISIBLE
val filterColor = 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.TRANSPARENT
}
binding.imgFilterOverlay.setBackgroundColor(filterColor)
}
}
private fun updateFrameModeIcon(frame: FrameItem) {
if (frame.id == "DEFAULT" || frame.imageFileName.isEmpty()) {
binding.btnModeFrame.setImageResource(R.drawable.ic_default_frame_thumbnail)
} else {
try {
val inputStream = assets.open(frame.imageFileName)
val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
binding.btnModeFrame.setImageBitmap(bitmap)
} catch (e: Exception) {
binding.btnModeFrame.setImageResource(R.drawable.ic_default_frame_thumbnail)
}
}
}
private fun updatePresetModeIcon(preset: ColorPreset) {
if (preset.id == "DEFAULT") {
binding.btnModePreset.setImageResource(R.drawable.ic_default_preset_thumbnail)
} 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.btnModePreset.setImageDrawable(circleDrawable)
}
}
private fun setupCameraControls() {
// Toggle Switch Camera Button
// Mode Selectors
binding.btnModeFrame.setOnClickListener {
toggleFrameMode()
}
binding.btnModePreset.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.btnReviewGallery.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 {
onCaptureClick()
}
}
private fun toggleFrameMode() {
if (isCountingDown) return
if (!isFrameModeOpen) {
isFrameModeOpen = true
isPresetModeOpen = false
// Show Shared timeline under frame mode, hide preset button
binding.btnModePreset.visibility = android.view.View.GONE
binding.rvSharedTimeline.visibility = android.view.View.VISIBLE
binding.rvSharedTimeline.adapter = frameItemAdapter
} else {
isFrameModeOpen = false
// Collapse Frame UI, restore Preset button
binding.btnModePreset.visibility = android.view.View.VISIBLE
binding.rvSharedTimeline.visibility = android.view.View.GONE
}
}
private fun togglePresetMode() {
if (isCountingDown) return
if (!isPresetModeOpen) {
isPresetModeOpen = true
isFrameModeOpen = false
// Show Shared timeline with Presets, hide Frame button
binding.btnModeFrame.visibility = android.view.View.GONE
binding.rvSharedTimeline.visibility = android.view.View.VISIBLE
presetAdapter.resetSelection()
binding.rvSharedTimeline.adapter = presetAdapter
} else {
isPresetModeOpen = false
// Collapse Preset UI, restore Frame button
binding.btnModeFrame.visibility = android.view.View.VISIBLE
binding.rvSharedTimeline.visibility = android.view.View.GONE
}
}
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
@@ -196,15 +329,19 @@ 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
isFrameModeOpen = false
isPresetModeOpen = false
binding.btnModeFrame.visibility = android.view.View.VISIBLE
binding.btnModePreset.visibility = android.view.View.VISIBLE
binding.rvSharedTimeline.visibility = android.view.View.GONE
setFrameOverlay("")
applyPresetFilter(colorPresets[0])
updateFrameModeIcon(FrameItem("DEFAULT", "", ""))
updatePresetModeIcon(colorPresets[0])
startCamera()
}
@@ -214,16 +351,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 +372,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 +404,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,81 @@
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.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
) : RecyclerView.Adapter<PresetAdapter.ViewHolder>() {
private var selectedPosition = 0 // Mặc định là Mặc định (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" // Orange Warm
"instax_bw_cool" -> "#FF808080" // Gray B&W
"instax_cool_summer" -> "#FF00BCD4" // Cyan Cool
else -> "#FF9E9E9E"
}
val circleDrawable = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor(colorHex))
}
holder.viewPresetColor.background = circleDrawable
}
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
}
holder.itemView.setOnClickListener {
val previousSelected = selectedPosition
selectedPosition = position
notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition)
onPresetSelected(preset)
}
}
override fun getItemCount(): Int = presets.size
fun resetSelection() {
val previousSelected = selectedPosition
selectedPosition = 0
notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition)
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/theme_white"
android:pathData="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="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="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="@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>
@@ -16,47 +16,45 @@
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) ================= -->
<!-- ================= 2. KHUNG FRAME CHÍNH (CAMERA + FILTER + FRAME OVERLAY) ================= -->
<FrameLayout
android:id="@+id/cameraContainer"
android:layout_width="match_parent"
@@ -71,6 +69,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"
@@ -96,88 +103,109 @@
android:text="3" />
</FrameLayout>
<!-- ================= 3. TIMELINE DANH SÁCH STACK FRAMES ================= -->
<!-- ================= 3. TIMELINE DANH SÁCH FRAMES / PRESETS DÙNG CHUNG ================= -->
<LinearLayout
android:id="@+id/timelineContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@id/cameraContainer"
app:layout_constraintBottom_toTopOf="@id/zoomControls">
app:layout_constraintBottom_toTopOf="@id/toggleModeContainer">
<!-- TẦNG BỔ SUNG: RecyclerView hiển thị các mẫu khung con bên trong Stack -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSubFrames"
android:id="@+id/rvSharedTimeline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="6dp"
android:background="@color/theme_sub_bar_bg"
android:visibility="gone" />
<!-- TẦNG GỐC: Danh sách các Stack chủ đề lớn -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvFrameStacks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp" />
</LinearLayout>
<!-- ================= 4. CỤM NÚT ĐIỀU KHIỂN ZOOM ================= -->
<!-- ================= 4. TOGGLE CHẾ ĐỘ FRAME / PRESETS ================= -->
<LinearLayout
android:id="@+id/toggleModeContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginVertical="8dp"
app:layout_constraintTop_toBottomOf="@id/timelineContainer"
app:layout_constraintBottom_toTopOf="@id/zoomControls"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<ImageView
android:id="@+id/btnModeFrame"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginHorizontal="12dp"
android:padding="4dp"
android:src="@drawable/ic_default_frame_thumbnail"
android:background="@color/theme_transparent"
android:scaleType="fitCenter"
android:contentDescription="Frame Selector" />
<ImageView
android:id="@+id/btnModePreset"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginHorizontal="12dp"
android:padding="4dp"
android:src="@drawable/ic_default_preset_thumbnail"
android:background="@color/theme_transparent"
android:scaleType="fitCenter"
android:contentDescription="Preset Color Selector" />
</LinearLayout>
<!-- ================= 5. CỤM NÚT ĐIỀU KHIỂN ZOOM (TEXTVIEW) ================= -->
<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:background="@color/theme_transparent"
android:padding="4dp"
app:layout_constraintTop_toBottomOf="@id/timelineContainer"
app:layout_constraintTop_toBottomOf="@id/toggleModeContainer"
app:layout_constraintBottom_toTopOf="@id/controlPanel"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<Button
android:id="@+id/btnZoom05"
<TextView
android:id="@+id/tvZoom05"
android:layout_width="50dp"
android:layout_height="36dp"
android:layout_marginHorizontal="2dp"
android:backgroundTint="@color/theme_item_card_bg"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/theme_white"
android:text="@string/zoom_05x"
android:textSize="10sp"
android:padding="0dp"
android:insetTop="0dp"
android:insetBottom="0dp" />
android:textSize="14sp"
android:textStyle="bold"
android:padding="6dp" />
<Button
android:id="@+id/btnZoom10"
<TextView
android:id="@+id/tvZoom1"
android:layout_width="50dp"
android:layout_height="36dp"
android:layout_marginHorizontal="2dp"
android:backgroundTint="@color/theme_primary"
android:textColor="@color/theme_white"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/theme_primary"
android:text="@string/zoom_1x"
android:textSize="10sp"
android:padding="0dp"
android:insetTop="0dp"
android:insetBottom="0dp" />
android:textSize="14sp"
android:textStyle="bold"
android:padding="6dp" />
<Button
android:id="@+id/btnZoom30"
<TextView
android:id="@+id/tvZoom3"
android:layout_width="50dp"
android:layout_height="36dp"
android:layout_marginHorizontal="2dp"
android:backgroundTint="@color/theme_item_card_bg"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/theme_white"
android:text="@string/zoom_3x"
android:textSize="10sp"
android:padding="0dp"
android:insetTop="0dp"
android:insetBottom="0dp" />
android:textSize="14sp"
android:textStyle="bold"
android:padding="6dp" />
</LinearLayout>
<!-- ================= 5. CONTROL PANEL CHỤP ẢNH ================= -->
<!-- ================= 6. CONTROL PANEL CHỤP ẢNH (PLAY + SHUTTER) ================= -->
<RelativeLayout
android:id="@+id/controlPanel"
android:layout_width="match_parent"
@@ -186,6 +214,20 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/zoomControls">
<!-- 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="@color/theme_transparent"
android:src="@drawable/ic_play_gallery"
app:tint="@color/theme_white"
android:contentDescription="Review Gallery" />
<!-- Nút Chụp (Ở giữa) -->
<ImageButton
android:id="@+id/btnCapture"
android:layout_width="64dp"
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-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/cardPreset"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_margin="4dp"
app:cardCornerRadius="6dp"
app:cardElevation="1dp"
app:strokeWidth="0dp"
app:cardBackgroundColor="@color/theme_item_card_bg">
<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>