Implement 4_ANDROID_UI: Two-tier Frame Selector UI (RecyclerViews, Adapters, Real-time Overlay) with standardized resources (strings, colors, assets configurations)

This commit is contained in:
2026-07-05 16:18:50 +07:00
parent 9f9ec87d54
commit 53926a3f95
14 changed files with 721 additions and 82 deletions
+233
View File
@@ -0,0 +1,233 @@
# 📱 KẾ HOẠCH PHÁT TRIỂN: GIAO DIỆN CHỌN FRAME THEO STACK (REAL-TIME OVERLAY)
Mục tiêu: Xây dựng bố cục giao diện gồm khu vực Top Bar điều khiển, Khung camera chính tích hợp Frame Overlay động, danh sách các Stack chủ đề dạng thanh trượt ngang (Timeline), cụm nút Zoom và Control Panel chụp ảnh phía dưới.
---
## 📁 1. Chuẩn Bị Cấu Trúc Dữ Liệu Model (Kotlin)
Để quản lý các Frame được chia theo từng Stack chủ đề như trong file `image_28b4c8.png`, chúng ta định nghĩa một cấu trúc dữ liệu rõ ràng:
```kotlin
data class FrameItem(
val id: String,
val name: String,
val imageFileName: String // Tên file PNG trong suốt nằm trong thư mục assets
)
data class FrameStack(
val id: String,
val themeName: String, // Ví dụ: "Retro", "Vintage", "Summer"
val frames: List<FrameItem> // Danh sách các frame thuộc chủ đề này
)
```
---
## 🛠️ 2. Cấu Trúc XML Bố Cục Giao Diện (`activity_main.xml`)
Bố cục được chia tầng bằng `ConstraintLayout` kết hợp với `ImageView` đóng vai trò làm lớp Overlay đè lên `PreviewView` của CameraX.
```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res/auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#121212">
<!-- ================= 1. TOP BAR CONTROL ================= -->
<LinearLayout
android:id="@+id/topBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
app:layout_constraintTop_toTopOf="parent">
<!-- Các nút AUTO FLASH, TIMER OFF, FRONT CAMERA như trong image_28b4c8.png -->
</LinearLayout>
<!-- ================= 2. KHUNG FRAME CHÍNH (CAMERA + 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">
<!-- Lớp đáy: Kính ngắm CameraX -->
<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Lớp đè (Overlay): Hiển thị Khung hình PNG do người dùng chọn -->
<ImageView
android:id="@+id/imgFrameOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:contentDescription="Frame Overlay" />
</FrameLayout>
<!-- ================= 3. TIMELINE DANH SÁCH STACK FRAMES ================= -->
<!-- Thanh trượt ngang hiển thị các Stack 1 Retro, Stack 2 Vintage... -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvFrameStacks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
app:layout_constraintTop_toBottomOf="@id/cameraContainer" />
<!-- ================= 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="#1A1A1A"
android:padding="4dp"
android:layout_marginTop="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/rvFrameStacks">
<!-- Các nút 0.5x, 1x, 3x xếp ngang -->
</LinearLayout>
<!-- ================= 5. CONTROL PANEL CHỤP ẢNH ================= -->
<RelativeLayout
android:id="@+id/controlPanel"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/zoomControls">
<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" />
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
```
---
## 💻 3. Kịch Bản Logic Điều Khiển Giao Diện (Kotlin)
Quy trình tương tác khi người dùng thao tác trên giao diện timeline:
1. **Nạp danh sách Stack từ Assets:** Đọc dữ liệu.
Ứng dụng quét thư mục `assets/frames/`, khởi tạo danh sách `List<FrameStack>` gồm các chủ đề kèm ảnh đại diện cho từng Stack rồi đẩy dữ liệu vào `rvFrameStacks`.
2. **Người dùng chọn một Stack:** Sự kiện chạm.
Khi chạm vào một Stack (ví dụ: "Stack 2 Vintage" trong file `image_28b4c8.png`), giao diện có thể bung ra một hàng ngang phụ (Sub-timeline) hiển thị các mẫu khung con bên trong hoặc tự động áp dụng khung mặc định đầu tiên của Stack đó.
3. **Hiển thị Overlay thời gian thực:** Cập nhật UI.
Đọc file ảnh PNG tương ứng từ assets bằng `BitmapFactory.decodeStream()`. Gán bitmap này vào `imgFrameOverlay`. Nhờ lớp `FrameLayout`, khung ảnh ngay lập tức hiển thị đè mịn màng lên luồng preview của camera.
---
## 📅 4. Các Đầu Việc Cần Làm Tiếp Theo (Checklist)
* [ ] **Tạo Adapter cho RecyclerView:** Viết `FrameStackAdapter` để hiển thị các ô vuông "Stack 1 Retro", "Stack 2 Vintage" với viền đỏ bo góc như thiết kế trong ảnh `image_28b4c8.png`.
* [ ] **Xử lý hiệu ứng Selection:** Khi một Stack được bấm chọn, vẽ một viền màu nổi bật (hoặc đổi màu background của ô) để người dùng biết họ đang ở chế độ nào.
* [ ] **Đồng bộ ảnh chụp cuối cùng:** Đảm bảo khi bấm chụp (`btnCapture`), thuật toán gộp ảnh sẽ lấy chính xác ID của frame đang hiển thị trên `imgFrameOverlay` để vẽ đè lên ảnh độ phân giải cao.
## 🛠️ 5. Cấu Trúc Lại XML Giao Diện (`activity_main.xml`)
Chúng ta sẽ chèn thêm một `RecyclerView` thứ hai nằm ngay trên danh sách các Stack để làm thanh chọn khung con.
```xml
<!-- Thay thế phần 3 (TIMELINE DANH SÁCH STACK FRAMES) ở kế hoạch cũ bằng cụm này -->
<LinearLayout
android:id="@+id/timelineContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@id/cameraContainer">
<!-- TẦNG BỔ SUNG: RecyclerView hiển thị các mẫu khung con bên trong Stack -->
<!-- Mặc định khi mới mở app, thanh này ẩn (android:visibility="gone") -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSubFrames"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="6dp"
android:background="#1E1E1E" />
<!-- TẦNG GỐC (Giống ảnh image_28b4c8.png): 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>
```
---
## 💻 6. Cấu Trúc Logic Cập Nhật & Tạo Nút Mặc Định (Kotlin)
### 🔹 6.1. Cập nhật Model Dữ Liệu
Để tạo ra nút "Mặc định", chúng ta sẽ quy ước một đối tượng `FrameItem` đặc biệt với ID là `"NONE"` hoặc `"DEFAULT"`.
```kotlin
// Hàm khởi tạo dữ liệu mẫu cho một Stack luôn đi kèm nút Mặc định ở đầu
fun getFramesForStack(stackId: String): List<FrameItem> {
val subFrames = mutableListOf<FrameItem>()
// Luôn thêm phần tử Mặc định (Không dùng khung) vào vị trí đầu tiên [0]
subFrames.add(FrameItem(id = "DEFAULT", name = "Mặc định", imageFileName = ""))
// Tải các khung thực tế thuộc chủ đề từ Assets
when(stackId) {
"retro" -> {
subFrames.add(FrameItem("r1", "Retro Polaroid", "retro_1.png"))
subFrames.add(FrameItem("r2", "Retro Film 35mm", "retro_2.png"))
}
"vintage" -> {
subFrames.add(FrameItem("v1", "Vintage Wood", "vintage_1.png"))
}
}
return subFrames
}
```
### 🔹 6.2. Kịch Bản Tương Tác Hai Tầng (User Interaction Flow)
1. **Bước 1: Người dùng chạm vào Stack (Tầng 1):** Chọn chủ đề chính.
Khi bấm vào "Stack 2 Vintage" (từ layout `image_28b4c8.png`), app kích hoạt hiển thị `rvSubFrames` (`visibility = View.VISIBLE`). Đồng thời nạp danh sách khung con tương ứng vào thanh này.
2. **Bước 2: Người dùng chọn Khung con (Tầng 2):** Chọn mẫu cụ thể.
Người dùng vuốt và chọn một mẫu khung con cụ thể. Nếu chọn trúng các mẫu như `v1`, `r1`, ảnh PNG tương ứng từ assets sẽ được render đè lên màn hình camera thông qua lớp `imgFrameOverlay`.
3. **Bước 3: Quay về trạng thái Mặc định:** Hủy bỏ bộ lọc.
Nếu người dùng không ưng ý, họ chạm vào phần tử đầu tiên (ô "Mặc định"). Logic code lập tức gọi lệnh `imgFrameOverlay.setImageDrawable(null)`, xóa bỏ hoàn toàn khung overlay hiện tại để camera quay về trạng thái gốc sạch sẽ.
---
## 📅 7. Các Đầu Việc Cần Triển Khai (Checklist)
* [ ] **Thiết kế UI cho ô "Mặc định":** Tạo một file ảnh icon hoặc vẽ một hình tròn có gạch chéo đỏ nhẹ (biểu tượng đóng/bỏ chọn) để làm ảnh đại diện (thumbnail) cho ô Mặc định trong danh sách con.
* [ ] **Quản lý biến trạng thái toàn cục:** Tạo biến `private var currentSelectedFramePath: String? = null`. Nếu biến này bằng `null` (khi chọn mặc định), hàm chụp ảnh cuối cùng sẽ hiểu là chỉ lưu ảnh camera gốc mà không thực hiện gộp layer.
* [ ] **Hiệu ứng thu gọn (Ẩn thanh con):** Cài đặt logic khi người dùng bấm lại vào chính Stack chủ đề đang chọn lần thứ hai, thanh `rvSubFrames` sẽ tự động ẩn đi (`View.GONE`) để giao diện gọn gàng hơn.
+5
View File
@@ -36,6 +36,11 @@ android {
buildFeatures { buildFeatures {
viewBinding = true // Bật ViewBinding để thao tác với giao diện dễ dàng viewBinding = true // Bật ViewBinding để thao tác với giao diện dễ dàng
} }
sourceSets {
getByName("main") {
assets.srcDirs("../../assets")
}
}
} }
dependencies { dependencies {
@@ -0,0 +1,76 @@
package com.photobooth.app
import android.graphics.BitmapFactory
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.card.MaterialCardView
class FrameItemAdapter(
private var items: List<FrameItem>,
private val onFrameSelected: (FrameItem) -> Unit
) : RecyclerView.Adapter<FrameItemAdapter.ViewHolder>() {
private var selectedPosition = 0 // Mặc định ở vị trí 0 (Mặc định - Không dùng khung)
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val cardSubFrame: MaterialCardView = view.findViewById(R.id.cardSubFrame)
val imgFrameThumb: ImageView = view.findViewById(R.id.imgFrameThumb)
val txtFrameName: TextView = view.findViewById(R.id.txtFrameName)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_frame_sub, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.txtFrameName.text = item.name
// Set thumbnail
if (item.id == "DEFAULT") {
holder.imgFrameThumb.setImageResource(R.drawable.ic_block_white)
holder.txtFrameName.text = holder.itemView.context.getString(R.string.default_frame)
} else {
try {
val inputStream = holder.itemView.context.assets.open("frames/${item.imageFileName}")
val bitmap = BitmapFactory.decodeStream(inputStream)
holder.imgFrameThumb.setImageBitmap(bitmap)
} catch (e: Exception) {
holder.imgFrameThumb.setImageDrawable(null)
}
}
val strokeWidthPx = (3 * holder.itemView.context.resources.displayMetrics.density).toInt()
if (position == selectedPosition) {
holder.cardSubFrame.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_primary)
holder.cardSubFrame.strokeWidth = strokeWidthPx
} else {
holder.cardSubFrame.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_transparent)
holder.cardSubFrame.strokeWidth = 0
}
holder.itemView.setOnClickListener {
val previousSelected = selectedPosition
selectedPosition = position
notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition)
onFrameSelected(item)
}
}
override fun getItemCount(): Int = items.size
fun updateItems(newItems: List<FrameItem>) {
items = newItems
selectedPosition = 0 // Reset to default when switching stack
notifyDataSetChanged()
}
}
@@ -0,0 +1,13 @@
package com.photobooth.app
data class FrameItem(
val id: String,
val name: String,
val imageFileName: String // Tên file PNG trong suốt nằm trong thư mục assets
)
data class FrameStack(
val id: String,
val themeName: String, // Ví dụ: "Retro", "Vintage", "Summer"
val frames: List<FrameItem> // Danh sách các frame thuộc chủ đề này
)
@@ -0,0 +1,65 @@
package com.photobooth.app
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 FrameStackAdapter(
private val stacks: List<FrameStack>,
private val onStackSelected: (FrameStack, Int) -> Unit
) : RecyclerView.Adapter<FrameStackAdapter.ViewHolder>() {
private var selectedPosition = -1
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val cardStack: MaterialCardView = view.findViewById(R.id.cardStack)
val txtStackName: TextView = view.findViewById(R.id.txtStackName)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_frame_stack, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val stack = stacks[position]
holder.txtStackName.text = stack.themeName
val strokeWidthPx = (3 * holder.itemView.context.resources.displayMetrics.density).toInt()
if (position == selectedPosition) {
holder.cardStack.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_red_stroke)
holder.cardStack.strokeWidth = strokeWidthPx
} else {
holder.cardStack.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_transparent)
holder.cardStack.strokeWidth = 0
}
holder.itemView.setOnClickListener {
val previousSelected = selectedPosition
selectedPosition = if (selectedPosition == position) {
-1 // Toggle collapse if clicked again
} else {
position
}
notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition)
onStackSelected(stack, selectedPosition)
}
}
override fun getItemCount(): Int = stacks.size
fun clearSelection() {
val previousSelected = selectedPosition
selectedPosition = -1
if (previousSelected != -1) {
notifyItemChanged(previousSelected)
}
}
}
@@ -13,6 +13,7 @@ import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import com.photobooth.app.databinding.ActivityMainBinding import com.photobooth.app.databinding.ActivityMainBinding
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -30,6 +31,12 @@ class MainActivity : AppCompatActivity() {
private var imageCapture: ImageCapture? = null // Capture usecase private var imageCapture: ImageCapture? = null // Capture usecase
private var isCountingDown = false // Countdown state flag private var isCountingDown = false // Countdown state flag
// Frame UI selectors state
private lateinit var frameStacks: List<FrameStack>
private lateinit var stackAdapter: FrameStackAdapter
private lateinit var frameItemAdapter: FrameItemAdapter
private var currentSelectedFramePath: String? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
@@ -38,6 +45,10 @@ class MainActivity : AppCompatActivity() {
// Setup UI control listeners // Setup UI control listeners
setupCameraControls() setupCameraControls()
// Setup Frame selection lists
initFrameData()
setupRecyclerViews()
// Request camera permission // Request camera permission
if (allPermissionsGranted()) { if (allPermissionsGranted()) {
startCamera() startCamera()
@@ -48,6 +59,63 @@ class MainActivity : AppCompatActivity() {
cameraExecutor = Executors.newSingleThreadExecutor() cameraExecutor = Executors.newSingleThreadExecutor()
} }
private fun initFrameData() {
val retroFrames = listOf(
FrameItem("DEFAULT", "", ""), // Default/No Frame
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)
)
}
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 ->
setFrameOverlay(frame.imageFileName)
}
binding.rvSubFrames.adapter = frameItemAdapter
}
private fun setFrameOverlay(fileName: String) {
if (fileName.isEmpty()) {
binding.imgFrameOverlay.setImageDrawable(null)
currentSelectedFramePath = null
} else {
try {
val inputStream = assets.open(fileName)
val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
binding.imgFrameOverlay.setImageBitmap(bitmap)
currentSelectedFramePath = fileName
} catch (e: Exception) {
val errorMsg = getString(R.string.toast_frame_load_error, fileName)
Toast.makeText(this, errorMsg, Toast.LENGTH_SHORT).show()
}
}
}
private fun setupCameraControls() { private fun setupCameraControls() {
// Toggle Switch Camera Button // Toggle Switch Camera Button
binding.btnSwitchCamera.setOnClickListener { binding.btnSwitchCamera.setOnClickListener {
@@ -115,7 +183,8 @@ class MainActivity : AppCompatActivity() {
// Reset zoom to 1.0x on start/switch // Reset zoom to 1.0x on start/switch
applyZoom(1.0f) applyZoom(1.0f)
} catch (xc: Exception) { } catch (xc: Exception) {
Toast.makeText(this, "Không thể khởi động Camera", Toast.LENGTH_SHORT).show() val errorMsg = getString(R.string.toast_camera_start_error)
Toast.makeText(this, errorMsg, Toast.LENGTH_SHORT).show()
} }
}, ContextCompat.getMainExecutor(this)) }, ContextCompat.getMainExecutor(this))
} }
@@ -127,7 +196,15 @@ class MainActivity : AppCompatActivity() {
} else { } else {
CameraSelector.DEFAULT_FRONT_CAMERA CameraSelector.DEFAULT_FRONT_CAMERA
} }
binding.btnSwitchCamera.text = if (lensFacing == CameraSelector.DEFAULT_FRONT_CAMERA) "🔄 Front" else "🔄 Back" 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
setFrameOverlay("")
startCamera() startCamera()
} }
@@ -139,9 +216,9 @@ class MainActivity : AppCompatActivity() {
} }
binding.btnFlash.text = when (flashMode) { binding.btnFlash.text = when (flashMode) {
ImageCapture.FLASH_MODE_ON -> "⚡ On" ImageCapture.FLASH_MODE_ON -> getString(R.string.flash_on)
ImageCapture.FLASH_MODE_AUTO -> "⚡ Auto" ImageCapture.FLASH_MODE_AUTO -> getString(R.string.flash_auto)
else -> "⚡ Off" else -> getString(R.string.flash_off)
} }
imageCapture?.flashMode = flashMode imageCapture?.flashMode = flashMode
@@ -157,8 +234,8 @@ class MainActivity : AppCompatActivity() {
} }
binding.btnTimer.text = when (timerSeconds) { binding.btnTimer.text = when (timerSeconds) {
0 -> "⏱️ Off" 0 -> getString(R.string.timer_off)
else -> "⏱️ ${timerSeconds}s" else -> getString(R.string.timer_seconds, timerSeconds)
} }
} }
@@ -171,7 +248,8 @@ class MainActivity : AppCompatActivity() {
cameraControl?.setZoomRatio(ratio) cameraControl?.setZoomRatio(ratio)
updateZoomButtonsUI(ratio) updateZoomButtonsUI(ratio)
} else { } else {
Toast.makeText(this, "Thiết bị không hỗ trợ mức zoom ${ratio}x", Toast.LENGTH_SHORT).show() val errorMsg = getString(R.string.toast_zoom_unsupported, ratio)
Toast.makeText(this, errorMsg, Toast.LENGTH_SHORT).show()
} }
} ?: run { } ?: run {
// Fallback for emulator where zoomState might be null on start // Fallback for emulator where zoomState might be null on start
@@ -181,14 +259,14 @@ class MainActivity : AppCompatActivity() {
} }
private fun updateZoomButtonsUI(ratio: Float) { private fun updateZoomButtonsUI(ratio: Float) {
binding.btnZoom05.backgroundTintList = android.content.res.ColorStateList.valueOf( binding.btnZoom05.backgroundTintList = ContextCompat.getColorStateList(
android.graphics.Color.parseColor(if (ratio == 0.5f) "#FF9800" else "#2D2D2D") this, if (ratio == 0.5f) R.color.theme_primary else R.color.theme_item_card_bg
) )
binding.btnZoom10.backgroundTintList = android.content.res.ColorStateList.valueOf( binding.btnZoom10.backgroundTintList = ContextCompat.getColorStateList(
android.graphics.Color.parseColor(if (ratio == 1.0f) "#FF9800" else "#2D2D2D") this, if (ratio == 1.0f) R.color.theme_primary else R.color.theme_item_card_bg
) )
binding.btnZoom30.backgroundTintList = android.content.res.ColorStateList.valueOf( binding.btnZoom30.backgroundTintList = ContextCompat.getColorStateList(
android.graphics.Color.parseColor(if (ratio == 3.0f) "#FF9800" else "#2D2D2D") this, if (ratio == 3.0f) R.color.theme_primary else R.color.theme_item_card_bg
) )
} }
@@ -214,7 +292,7 @@ class MainActivity : AppCompatActivity() {
val toneG = android.media.ToneGenerator(android.media.AudioManager.STREAM_ALARM, 50) val toneG = android.media.ToneGenerator(android.media.AudioManager.STREAM_ALARM, 50)
toneG.startTone(android.media.ToneGenerator.TONE_PROP_BEEP, 100) toneG.startTone(android.media.ToneGenerator.TONE_PROP_BEEP, 100)
} catch (e: Exception) { } catch (e: Exception) {
// Fallback if ToneGenerator fails // Fallback
} }
} }
@@ -234,7 +312,8 @@ class MainActivity : AppCompatActivity() {
} catch (e: Exception) { } catch (e: Exception) {
// Fallback // Fallback
} }
Toast.makeText(this, "📸 Tách! Chụp ảnh thành công!", Toast.LENGTH_SHORT).show() val successMsg = getString(R.string.toast_capture_success)
Toast.makeText(this, successMsg, Toast.LENGTH_SHORT).show()
} }
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
@@ -251,7 +330,8 @@ class MainActivity : AppCompatActivity() {
if (allPermissionsGranted()) { if (allPermissionsGranted()) {
startCamera() startCamera()
} else { } else {
Toast.makeText(this, "Quyền Camera bị từ chối.", Toast.LENGTH_SHORT).show() val errorMsg = getString(R.string.toast_camera_permission_denied)
Toast.makeText(this, errorMsg, Toast.LENGTH_SHORT).show()
} }
} }
} }
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#40FFFFFF">
<item>
<shape android:shape="oval">
<solid android:color="@color/theme_white" />
<stroke android:width="4dp" android:color="@color/theme_primary" />
<size android:width="64dp" android:height="64dp" />
</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="@color/theme_white"
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,-8 0,-1.85 0.63,-3.55 1.69,-4.9L16.9,18.31C15.55,19.37 13.85,20 12,20zm6.31,-3.1L6.9,5.69C8.25,4.63 9.95,4 12,4c4.41,0 8,3.59 8,8 0,1.85 -0.63,3.55 -1.69,4.9z" />
</vector>
@@ -0,0 +1,12 @@
<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,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0" />
<path
android:fillColor="@color/theme_white"
android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zm3,15c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z" />
</vector>
@@ -3,99 +3,136 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="#121212"> android:background="@color/theme_bg_dark">
<!-- Màn hình xem trước Camera --> <!-- ================= 1. TOP BAR CONTROL ================= -->
<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="3:4"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/controlPanel" />
<!-- Thanh công cụ trên đầu (Translucent Black Top Bar) -->
<LinearLayout <LinearLayout
android:id="@+id/topBar" android:id="@+id/topBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="60dp" android:layout_height="60dp"
android:background="#80000000" android:background="@color/theme_bar_bg"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center" android:gravity="center"
android:paddingHorizontal="16dp" android:paddingHorizontal="16dp"
app:layout_constraintTop_toTopOf="parent"> app:layout_constraintTop_toTopOf="parent">
<!-- Nút Flash -->
<Button <Button
android:id="@+id/btnFlash" android:id="@+id/btnFlash"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:backgroundTint="#2D2D2D" android:backgroundTint="@color/theme_item_card_bg"
android:textColor="#FFFFFF" android:textColor="@color/theme_white"
android:text="⚡ Off" android:text="@string/flash_off"
android:textSize="12sp" android:textSize="12sp"
android:insetTop="0dp" android:insetTop="0dp"
android:insetBottom="0dp" /> android:insetBottom="0dp" />
<!-- Nút Hẹn giờ -->
<Button <Button
android:id="@+id/btnTimer" android:id="@+id/btnTimer"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:backgroundTint="#2D2D2D" android:backgroundTint="@color/theme_item_card_bg"
android:textColor="#FFFFFF" android:textColor="@color/theme_white"
android:text="⏱️ Off" android:text="@string/timer_off"
android:textSize="12sp" android:textSize="12sp"
android:insetTop="0dp" android:insetTop="0dp"
android:insetBottom="0dp" /> android:insetBottom="0dp" />
<!-- Nút Đổi Camera -->
<Button <Button
android:id="@+id/btnSwitchCamera" android:id="@+id/btnSwitchCamera"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:backgroundTint="#2D2D2D" android:backgroundTint="@color/theme_item_card_bg"
android:textColor="#FFFFFF" android:textColor="@color/theme_white"
android:text="🔄 Front" android:text="@string/camera_front"
android:textSize="12sp" android:textSize="12sp"
android:insetTop="0dp" android:insetTop="0dp"
android:insetBottom="0dp" /> android:insetBottom="0dp" />
</LinearLayout> </LinearLayout>
<!-- ================= 2. KHUNG FRAME CHÍNH (CAMERA + 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">
<!-- Lớp đáy: Kính ngắm CameraX -->
<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Lớp đè (Overlay): Hiển thị Khung hình PNG do người dùng chọn -->
<ImageView
android:id="@+id/imgFrameOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:contentDescription="Frame Overlay" />
<!-- Số đếm ngược khi hẹn giờ chụp --> <!-- Số đếm ngược khi hẹn giờ chụp -->
<TextView <TextView
android:id="@+id/txtTimerCountdown" android:id="@+id/txtTimerCountdown"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="#FFFFFF" android:layout_gravity="center"
android:textColor="@color/theme_white"
android:textSize="100sp" android:textSize="100sp"
android:textStyle="bold" android:textStyle="bold"
android:shadowColor="#80000000" android:shadowColor="@color/theme_panel_dark"
android:shadowDx="4" android:shadowDx="4"
android:shadowDy="4" android:shadowDy="4"
android:shadowRadius="10" android:shadowRadius="10"
android:visibility="gone" android:visibility="gone"
android:text="3" android:text="3" />
app:layout_constraintTop_toTopOf="@id/viewFinder" </FrameLayout>
app:layout_constraintBottom_toBottomOf="@id/viewFinder"
app:layout_constraintLeft_toLeftOf="@id/viewFinder"
app:layout_constraintRight_toRightOf="@id/viewFinder" />
<!-- Thanh chọn Zoom ngay trên bảng điều khiển --> <!-- ================= 3. TIMELINE DANH SÁCH STACK FRAMES ================= -->
<LinearLayout <LinearLayout
android:id="@+id/zoomBar" 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">
<!-- 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: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 ================= -->
<LinearLayout
android:id="@+id/zoomControls"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="#80000000"
android:orientation="horizontal" android:orientation="horizontal"
android:background="@color/theme_sub_bar_bg"
android:padding="4dp" android:padding="4dp"
app:layout_constraintTop_toBottomOf="@id/timelineContainer"
app:layout_constraintBottom_toTopOf="@id/controlPanel" app:layout_constraintBottom_toTopOf="@id/controlPanel"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"> app:layout_constraintRight_toRightOf="parent">
@@ -105,9 +142,9 @@
android:layout_width="50dp" android:layout_width="50dp"
android:layout_height="36dp" android:layout_height="36dp"
android:layout_marginHorizontal="2dp" android:layout_marginHorizontal="2dp"
android:backgroundTint="#2D2D2D" android:backgroundTint="@color/theme_item_card_bg"
android:textColor="#FFFFFF" android:textColor="@color/theme_white"
android:text="0.5x" android:text="@string/zoom_05x"
android:textSize="10sp" android:textSize="10sp"
android:padding="0dp" android:padding="0dp"
android:insetTop="0dp" android:insetTop="0dp"
@@ -118,9 +155,9 @@
android:layout_width="50dp" android:layout_width="50dp"
android:layout_height="36dp" android:layout_height="36dp"
android:layout_marginHorizontal="2dp" android:layout_marginHorizontal="2dp"
android:backgroundTint="#FF9800" android:backgroundTint="@color/theme_primary"
android:textColor="#FFFFFF" android:textColor="@color/theme_white"
android:text="1x" android:text="@string/zoom_1x"
android:textSize="10sp" android:textSize="10sp"
android:padding="0dp" android:padding="0dp"
android:insetTop="0dp" android:insetTop="0dp"
@@ -131,32 +168,32 @@
android:layout_width="50dp" android:layout_width="50dp"
android:layout_height="36dp" android:layout_height="36dp"
android:layout_marginHorizontal="2dp" android:layout_marginHorizontal="2dp"
android:backgroundTint="#2D2D2D" android:backgroundTint="@color/theme_item_card_bg"
android:textColor="#FFFFFF" android:textColor="@color/theme_white"
android:text="3x" android:text="@string/zoom_3x"
android:textSize="10sp" android:textSize="10sp"
android:padding="0dp" android:padding="0dp"
android:insetTop="0dp" android:insetTop="0dp"
android:insetBottom="0dp" /> android:insetBottom="0dp" />
</LinearLayout> </LinearLayout>
<!-- Bảng điều khiển phía dưới --> <!-- ================= 5. CONTROL PANEL CHỤP ẢNH ================= -->
<RelativeLayout <RelativeLayout
android:id="@+id/controlPanel" android:id="@+id/controlPanel"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="120dp" android:layout_height="0dp"
android:background="#000000" android:background="@color/theme_panel_dark"
app:layout_constraintBottom_toBottomOf="parent"> app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/zoomControls">
<!-- Nút chụp hình -->
<ImageButton <ImageButton
android:id="@+id/btnCapture" android:id="@+id/btnCapture"
android:layout_width="70dp" android:layout_width="64dp"
android:layout_height="70dp" android:layout_height="64dp"
android:layout_centerInParent="true" android:layout_centerInParent="true"
android:background="@android:color/transparent" android:background="@drawable/bg_capture_button"
android:src="@android:drawable/ic_menu_camera" android:src="@drawable/ic_camera_white"
android:contentDescription="Capture Button" /> android:contentDescription="@string/btn_capture_desc" />
</RelativeLayout> </RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,23 @@
<?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/cardStack"
android:layout_width="120dp"
android:layout_height="50dp"
android:layout_margin="6dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:strokeWidth="0dp"
app:cardBackgroundColor="@color/theme_item_card_bg">
<TextView
android:id="@+id/txtStackName"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textColor="@color/theme_white"
android:textSize="14sp"
android:textStyle="bold"
android:padding="8dp"
android:text="Stack 1" />
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,38 @@
<?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/cardSubFrame"
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">
<ImageView
android:id="@+id/imgFrameThumb"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="fitCenter"
android:contentDescription="Frame Thumb" />
<TextView
android:id="@+id/txtFrameName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/theme_white"
android:textSize="10sp"
android:singleLine="true"
android:ellipsize="end"
android:text="Frame" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="theme_bg_dark">#121212</color>
<color name="theme_panel_dark">#000000</color>
<color name="theme_bar_bg">#80000000</color>
<color name="theme_sub_bar_bg">#1E1E1E</color>
<color name="theme_item_card_bg">#2D2D2D</color>
<color name="theme_primary">#FF9800</color>
<color name="theme_white">#FFFFFF</color>
<color name="theme_red_stroke">#D32F2F</color>
<color name="theme_transparent">#00000000</color>
</resources>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Retro Photobooth</string>
<string name="btn_capture_desc">Capture Button</string>
<!-- Camera controls translation -->
<string name="flash_off">⚡ Off</string>
<string name="flash_on">⚡ On</string>
<string name="flash_auto">⚡ Auto</string>
<string name="timer_off">⏱️ Off</string>
<string name="timer_seconds">⏱️ %1$ds</string>
<string name="camera_front">🔄 Front</string>
<string name="camera_back">🔄 Back</string>
<string name="default_frame">Mặc định</string>
<string name="zoom_05x">0.5x</string>
<string name="zoom_1x">1x</string>
<string name="zoom_3x">3x</string>
<!-- User feedback toasts -->
<string name="toast_camera_start_error">Không thể khởi động Camera</string>
<string name="toast_camera_permission_denied">Quyền Camera bị từ chối.</string>
<string name="toast_zoom_unsupported">Thiết bị không hỗ trợ mức zoom %1$.1fx</string>
<string name="toast_capture_success">📸 Tách! Chụp ảnh thành công!</string>
<string name="toast_frame_load_error">Không thể tải khung hình: %1$s</string>
</resources>