fix: chèn frame và lưu ảnh vào thư viện
This commit is contained in:
@@ -39,7 +39,12 @@ class FrameItemAdapter(
|
||||
holder.txtFrameName.text = holder.itemView.context.getString(R.string.default_frame)
|
||||
} else {
|
||||
try {
|
||||
val inputStream = holder.itemView.context.assets.open("frames/${item.imageFileName}")
|
||||
val path = if (item.imageFileName.startsWith("frames/")) {
|
||||
item.imageFileName
|
||||
} else {
|
||||
"frames/${item.imageFileName}"
|
||||
}
|
||||
val inputStream = holder.itemView.context.assets.open(path)
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
holder.imgFrameThumb.setImageBitmap(bitmap)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import com.photobooth.app.databinding.ActivityMainBinding
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import android.os.Build
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
@@ -75,8 +77,7 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun initFrameData() {
|
||||
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")
|
||||
FrameItem("instax_mini_single", "Instax Mini", "frames/instaxframe.png")
|
||||
)
|
||||
|
||||
// Initialize default Color Presets
|
||||
@@ -248,86 +249,82 @@ class MainActivity : AppCompatActivity() {
|
||||
Toast.makeText(this, errorMsg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
adjustViewFinderBounds()
|
||||
}
|
||||
|
||||
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)
|
||||
private fun adjustViewFinderBounds() {
|
||||
val framePath = currentSelectedFramePath
|
||||
if (framePath == null || framePath.isEmpty()) {
|
||||
// Reset viewFinder to match_parent
|
||||
val params = binding.viewFinder.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
params.width = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
params.height = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
params.topMargin = 0
|
||||
params.bottomMargin = 0
|
||||
params.leftMargin = 0
|
||||
params.rightMargin = 0
|
||||
binding.viewFinder.layoutParams = params
|
||||
|
||||
// Also reset filter overlay
|
||||
val filterParams = binding.imgFilterOverlay.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
filterParams.width = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
filterParams.height = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
filterParams.topMargin = 0
|
||||
filterParams.bottomMargin = 0
|
||||
filterParams.leftMargin = 0
|
||||
filterParams.rightMargin = 0
|
||||
binding.imgFilterOverlay.layoutParams = filterParams
|
||||
} 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.cameraContainer.post {
|
||||
val containerWidth = binding.cameraContainer.width
|
||||
val containerHeight = binding.cameraContainer.height
|
||||
if (containerWidth > 0 && containerHeight > 0) {
|
||||
// We know instaxframe is 1024 x 1207
|
||||
val imgW = 1024f
|
||||
val imgH = 1207f
|
||||
val aspect = imgW / imgH
|
||||
|
||||
val containerAspect = containerWidth.toFloat() / containerHeight.toFloat()
|
||||
|
||||
var targetWidth = containerWidth
|
||||
var targetHeight = containerHeight
|
||||
|
||||
if (containerAspect > aspect) {
|
||||
// Container is wider than the image aspect -> height is matched, width is scaled
|
||||
targetWidth = (containerHeight * aspect).toInt()
|
||||
} else {
|
||||
// Container is taller than the image aspect -> width is matched, height is scaled
|
||||
targetHeight = (containerWidth / aspect).toInt()
|
||||
}
|
||||
|
||||
val verticalMargin = (containerHeight - targetHeight) / 2
|
||||
val horizontalMargin = (containerWidth - targetWidth) / 2
|
||||
|
||||
// Adjust viewFinder params
|
||||
val params = binding.viewFinder.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
params.width = targetWidth
|
||||
params.height = targetHeight
|
||||
params.topMargin = verticalMargin
|
||||
params.bottomMargin = verticalMargin
|
||||
params.leftMargin = horizontalMargin
|
||||
params.rightMargin = horizontalMargin
|
||||
binding.viewFinder.layoutParams = params
|
||||
|
||||
// Adjust filter overlay params
|
||||
val filterParams = binding.imgFilterOverlay.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
filterParams.width = targetWidth
|
||||
filterParams.height = targetHeight
|
||||
filterParams.topMargin = verticalMargin
|
||||
filterParams.bottomMargin = verticalMargin
|
||||
filterParams.leftMargin = horizontalMargin
|
||||
filterParams.rightMargin = horizontalMargin
|
||||
binding.imgFilterOverlay.layoutParams = filterParams
|
||||
}
|
||||
}
|
||||
binding.imgFilterOverlay.setBackgroundColor(filterColor)
|
||||
applyColorMatrixToViewFinder(preset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyColorMatrixToViewFinder(preset: ColorPreset) {
|
||||
private fun getColorMatrixForPreset(preset: ColorPreset): android.graphics.ColorMatrix {
|
||||
val finalMatrix = android.graphics.ColorMatrix()
|
||||
|
||||
// 1. Saturation & Vibrance (Sắc độ bão hòa màu thực tế)
|
||||
@@ -384,6 +381,93 @@ class MainActivity : AppCompatActivity() {
|
||||
))
|
||||
finalMatrix.postConcat(tintMatrix)
|
||||
|
||||
return finalMatrix
|
||||
}
|
||||
|
||||
private fun getFilterColorForPreset(preset: ColorPreset): Int {
|
||||
if (preset.id == "DEFAULT") return android.graphics.Color.TRANSPARENT
|
||||
val isBeingEdited = editingPreset != null
|
||||
val useCalculation = isBeingEdited || (preset.id != "instax_faded_warm_01" && preset.id != "instax_bw_cool" && preset.id != "instax_cool_summer")
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 filterColor = getFilterColorForPreset(preset)
|
||||
binding.imgFilterOverlay.setBackgroundColor(filterColor)
|
||||
applyColorMatrixToViewFinder(preset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyColorMatrixToViewFinder(preset: ColorPreset) {
|
||||
val finalMatrix = getColorMatrixForPreset(preset)
|
||||
val paint = android.graphics.Paint().apply {
|
||||
colorFilter = android.graphics.ColorMatrixColorFilter(finalMatrix)
|
||||
}
|
||||
@@ -507,6 +591,7 @@ class MainActivity : AppCompatActivity() {
|
||||
if (isCountingDown) return
|
||||
val wrapper = binding.buttonsWrapper
|
||||
TransitionManager.beginDelayedTransition(wrapper)
|
||||
val density = resources.displayMetrics.density
|
||||
|
||||
if (!isFrameModeOpen) {
|
||||
isFrameModeOpen = true
|
||||
@@ -515,14 +600,16 @@ class MainActivity : AppCompatActivity() {
|
||||
// Hide preset button
|
||||
binding.btnMainPreset.visibility = android.view.View.GONE
|
||||
|
||||
// Translate Frame button to left edge anchor
|
||||
// Translate Frame button to left edge anchor with 12.5dp margin
|
||||
val params = binding.btnMainFrame.layoutParams as ConstraintLayout.LayoutParams
|
||||
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
params.endToStart = ConstraintLayout.LayoutParams.UNSET
|
||||
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
|
||||
params.leftMargin = (12.5f * density).toInt()
|
||||
binding.btnMainFrame.layoutParams = params
|
||||
|
||||
// Show horizontal timeline
|
||||
// Show horizontal timeline & solid dock background
|
||||
binding.viewLeftDockBackground.visibility = android.view.View.VISIBLE
|
||||
binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE
|
||||
binding.rvHorizontalTimeline.adapter = frameItemAdapter
|
||||
} else {
|
||||
@@ -533,12 +620,12 @@ class MainActivity : AppCompatActivity() {
|
||||
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
params.endToStart = binding.btnMainPreset.id
|
||||
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
|
||||
params.leftMargin = 0
|
||||
binding.btnMainFrame.layoutParams = params
|
||||
|
||||
// Show preset button
|
||||
// Show preset button & hide timeline/dock background
|
||||
binding.btnMainPreset.visibility = android.view.View.VISIBLE
|
||||
|
||||
// Hide timeline
|
||||
binding.viewLeftDockBackground.visibility = android.view.View.GONE
|
||||
binding.rvHorizontalTimeline.visibility = android.view.View.GONE
|
||||
}
|
||||
}
|
||||
@@ -547,6 +634,7 @@ class MainActivity : AppCompatActivity() {
|
||||
if (isCountingDown) return
|
||||
val wrapper = binding.buttonsWrapper
|
||||
TransitionManager.beginDelayedTransition(wrapper)
|
||||
val density = resources.displayMetrics.density
|
||||
|
||||
if (!isPresetModeOpen) {
|
||||
isPresetModeOpen = true
|
||||
@@ -555,14 +643,16 @@ class MainActivity : AppCompatActivity() {
|
||||
// Hide frame button
|
||||
binding.btnMainFrame.visibility = android.view.View.GONE
|
||||
|
||||
// Translate Preset button to left edge anchor (occupying Frame button start spot)
|
||||
// Translate Preset button to left edge anchor with 12.5dp margin
|
||||
val params = binding.btnMainPreset.layoutParams as ConstraintLayout.LayoutParams
|
||||
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
params.startToEnd = ConstraintLayout.LayoutParams.UNSET
|
||||
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
|
||||
params.leftMargin = (12.5f * density).toInt()
|
||||
binding.btnMainPreset.layoutParams = params
|
||||
|
||||
// Show horizontal timeline
|
||||
// Show horizontal timeline & solid dock background
|
||||
binding.viewLeftDockBackground.visibility = android.view.View.VISIBLE
|
||||
binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE
|
||||
presetAdapter.resetSelection()
|
||||
binding.rvHorizontalTimeline.adapter = presetAdapter
|
||||
@@ -574,12 +664,12 @@ class MainActivity : AppCompatActivity() {
|
||||
params.startToStart = ConstraintLayout.LayoutParams.UNSET
|
||||
params.startToEnd = binding.btnMainFrame.id
|
||||
params.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
params.leftMargin = 0
|
||||
binding.btnMainPreset.layoutParams = params
|
||||
|
||||
// Show frame button
|
||||
// Show frame button & hide timeline/dock background
|
||||
binding.btnMainFrame.visibility = android.view.View.VISIBLE
|
||||
|
||||
// Hide timeline
|
||||
binding.viewLeftDockBackground.visibility = android.view.View.GONE
|
||||
binding.rvHorizontalTimeline.visibility = android.view.View.GONE
|
||||
}
|
||||
}
|
||||
@@ -962,14 +1052,262 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun takePicture() {
|
||||
val imageCapture = this.imageCapture ?: return
|
||||
|
||||
// Show progress bar
|
||||
binding.progressBar.visibility = android.view.View.VISIBLE
|
||||
|
||||
// Setup output file
|
||||
val tempFile = java.io.File.createTempFile("raw_capture_", ".jpg", cacheDir)
|
||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(tempFile).build()
|
||||
|
||||
imageCapture.takePicture(
|
||||
outputOptions,
|
||||
cameraExecutor,
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
processAndSaveCapturedPhoto(tempFile)
|
||||
}
|
||||
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
runOnUiThread {
|
||||
binding.progressBar.visibility = android.view.View.GONE
|
||||
Toast.makeText(this@MainActivity, "Lỗi chụp ảnh: ${exception.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
if (tempFile.exists()) {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun processAndSaveCapturedPhoto(file: java.io.File) {
|
||||
try {
|
||||
val toneG = android.media.ToneGenerator(android.media.AudioManager.STREAM_ALARM, 100)
|
||||
toneG.startTone(android.media.ToneGenerator.TONE_PROP_ACK, 250)
|
||||
// 1. Decode captured file to Bitmap
|
||||
val rawBitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
|
||||
if (rawBitmap == null) {
|
||||
runOnUiThread {
|
||||
binding.progressBar.visibility = android.view.View.GONE
|
||||
Toast.makeText(this, "Không thể đọc dữ liệu ảnh chụp", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Adjust Orientation (EXIF + Mirroring for Front Camera)
|
||||
var processedBitmap = rawBitmap
|
||||
try {
|
||||
val exif = android.media.ExifInterface(file.absolutePath)
|
||||
val orientation = exif.getAttributeInt(
|
||||
android.media.ExifInterface.TAG_ORIENTATION,
|
||||
android.media.ExifInterface.ORIENTATION_NORMAL
|
||||
)
|
||||
val matrix = android.graphics.Matrix()
|
||||
var rotation = 0f
|
||||
when (orientation) {
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> rotation = 90f
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> rotation = 180f
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> rotation = 270f
|
||||
}
|
||||
if (rotation != 0f) {
|
||||
matrix.postRotate(rotation)
|
||||
}
|
||||
|
||||
// Front camera mirroring
|
||||
if (lensFacing == CameraSelector.DEFAULT_FRONT_CAMERA) {
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
|
||||
if (rotation != 0f || lensFacing == CameraSelector.DEFAULT_FRONT_CAMERA) {
|
||||
processedBitmap = android.graphics.Bitmap.createBitmap(
|
||||
rawBitmap, 0, 0, rawBitmap.width, rawBitmap.height, matrix, true
|
||||
)
|
||||
if (processedBitmap != rawBitmap) {
|
||||
rawBitmap.recycle()
|
||||
}
|
||||
}
|
||||
} catch (exifEx: Exception) {
|
||||
// Ignore exif exceptions
|
||||
}
|
||||
|
||||
// 3. Apply Current Preset Filters (ColorMatrix + Semi-transparent overlay color)
|
||||
val preset = currentSelectedPreset ?: colorPresets[0]
|
||||
|
||||
val filteredBitmap = android.graphics.Bitmap.createBitmap(
|
||||
processedBitmap.width, processedBitmap.height, android.graphics.Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = android.graphics.Canvas(filteredBitmap)
|
||||
val paint = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
isFilterBitmap = true
|
||||
}
|
||||
|
||||
if (preset.id != "DEFAULT") {
|
||||
val finalMatrix = getColorMatrixForPreset(preset)
|
||||
paint.colorFilter = android.graphics.ColorMatrixColorFilter(finalMatrix)
|
||||
}
|
||||
canvas.drawBitmap(processedBitmap, 0f, 0f, paint)
|
||||
processedBitmap.recycle()
|
||||
|
||||
val overlayColor = getFilterColorForPreset(preset)
|
||||
if (overlayColor != android.graphics.Color.TRANSPARENT) {
|
||||
val overlayPaint = android.graphics.Paint().apply {
|
||||
color = overlayColor
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
}
|
||||
canvas.drawRect(
|
||||
0f, 0f, filteredBitmap.width.toFloat(), filteredBitmap.height.toFloat(), overlayPaint
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Film Grain effect
|
||||
if (preset.grain.grainAmount > 0f) {
|
||||
val grainAmount = preset.grain.grainAmount
|
||||
val grainSize = preset.grain.grainSize.coerceAtLeast(0.01f)
|
||||
val width = filteredBitmap.width
|
||||
val height = filteredBitmap.height
|
||||
val pixels = IntArray(width * height)
|
||||
filteredBitmap.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
|
||||
val random = java.util.Random()
|
||||
val sizeFactor = (grainSize * 12).toInt().coerceAtLeast(1)
|
||||
|
||||
for (y in 0 until height step sizeFactor) {
|
||||
for (x in 0 until width step sizeFactor) {
|
||||
val noise = (random.nextGaussian() * 127 * grainAmount).toInt()
|
||||
for (ny in 0 until sizeFactor) {
|
||||
if (y + ny >= height) break
|
||||
for (nx in 0 until sizeFactor) {
|
||||
if (x + nx >= width) break
|
||||
val index = (y + ny) * width + (x + nx)
|
||||
val color = pixels[index]
|
||||
val r = (android.graphics.Color.red(color) + noise).coerceIn(0, 255)
|
||||
val g = (android.graphics.Color.green(color) + noise).coerceIn(0, 255)
|
||||
val b = (android.graphics.Color.blue(color) + noise).coerceIn(0, 255)
|
||||
pixels[index] = android.graphics.Color.rgb(r, g, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
filteredBitmap.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
}
|
||||
|
||||
// 5. Merge Instax Frame Overlay
|
||||
var finalBitmap = filteredBitmap
|
||||
val framePath = currentSelectedFramePath
|
||||
if (framePath != null && framePath.isNotEmpty()) {
|
||||
try {
|
||||
val frameInputStream = assets.open(framePath)
|
||||
val frameBitmap = android.graphics.BitmapFactory.decodeStream(frameInputStream)
|
||||
if (frameBitmap != null) {
|
||||
val W_frame = frameBitmap.width
|
||||
val H_frame = frameBitmap.height
|
||||
val R = W_frame.toFloat() / H_frame.toFloat()
|
||||
|
||||
val W_photo = filteredBitmap.width
|
||||
val H_photo = filteredBitmap.height
|
||||
val photoRatio = W_photo.toFloat() / H_photo.toFloat()
|
||||
|
||||
var cropX = 0
|
||||
var cropY = 0
|
||||
var cropW = W_photo
|
||||
var cropH = H_photo
|
||||
|
||||
if (photoRatio > R) {
|
||||
cropW = (H_photo * R).toInt()
|
||||
cropX = (W_photo - cropW) / 2
|
||||
} else {
|
||||
cropH = (W_photo / R).toInt()
|
||||
cropY = (H_photo - cropH) / 2
|
||||
}
|
||||
|
||||
val croppedPhoto = android.graphics.Bitmap.createBitmap(
|
||||
filteredBitmap, cropX, cropY, cropW, cropH
|
||||
)
|
||||
val resizedPhoto = android.graphics.Bitmap.createScaledBitmap(
|
||||
croppedPhoto, W_frame, H_frame, true
|
||||
)
|
||||
|
||||
if (croppedPhoto != filteredBitmap) {
|
||||
croppedPhoto.recycle()
|
||||
}
|
||||
filteredBitmap.recycle()
|
||||
|
||||
val mergeBitmap = android.graphics.Bitmap.createBitmap(
|
||||
W_frame, H_frame, android.graphics.Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val mergeCanvas = android.graphics.Canvas(mergeBitmap)
|
||||
mergeCanvas.drawBitmap(resizedPhoto, 0f, 0f, null)
|
||||
mergeCanvas.drawBitmap(frameBitmap, 0f, 0f, null)
|
||||
|
||||
resizedPhoto.recycle()
|
||||
frameBitmap.recycle()
|
||||
finalBitmap = mergeBitmap
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Save image to system Gallery
|
||||
val savedSuccess = saveBitmapToGallery(finalBitmap, "RetroPhoto")
|
||||
finalBitmap.recycle()
|
||||
|
||||
runOnUiThread {
|
||||
binding.progressBar.visibility = android.view.View.GONE
|
||||
if (savedSuccess) {
|
||||
Toast.makeText(this, "Đã lưu vào thư viện!", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this, "Lỗi khi lưu ảnh vào Gallery", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Fallback
|
||||
runOnUiThread {
|
||||
binding.progressBar.visibility = android.view.View.GONE
|
||||
Toast.makeText(this, "Lỗi xử lý ảnh: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} finally {
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
val successMsg = getString(R.string.toast_capture_success)
|
||||
Toast.makeText(this, successMsg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun saveBitmapToGallery(bitmap: android.graphics.Bitmap, title: String): Boolean {
|
||||
val filename = "${title}_${System.currentTimeMillis()}.jpg"
|
||||
var success = false
|
||||
|
||||
val contentResolver = contentResolver
|
||||
val imageDetails = android.content.ContentValues().apply {
|
||||
put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, filename)
|
||||
put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||
put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/RetroPhotobooth")
|
||||
put(android.provider.MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val imageUri = contentResolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageDetails)
|
||||
if (imageUri != null) {
|
||||
try {
|
||||
contentResolver.openOutputStream(imageUri).use { outputStream ->
|
||||
if (outputStream != null) {
|
||||
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 95, outputStream)
|
||||
success = true
|
||||
}
|
||||
}
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||
imageDetails.clear()
|
||||
imageDetails.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
|
||||
contentResolver.update(imageUri, imageDetails, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
contentResolver.delete(imageUri, null, null)
|
||||
success = false
|
||||
}
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
|
||||
@@ -999,6 +1337,10 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CODE_PERMISSIONS = 10
|
||||
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
|
||||
private val REQUIRED_PERMISSIONS = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
arrayOf(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
android:id="@+id/cameraContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:background="#000000"
|
||||
app:layout_constraintDimensionRatio="3:4"
|
||||
app:layout_constraintTop_toBottomOf="@id/topBar"
|
||||
app:layout_constraintBottom_toTopOf="@id/panelSelectionContainer">
|
||||
@@ -149,6 +150,14 @@
|
||||
android:paddingEnd="16dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Khung nền đen che các item cuộn sang bên trái -->
|
||||
<View
|
||||
android:id="@+id/viewLeftDockBackground"
|
||||
android:layout_width="75dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000"
|
||||
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) -->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/buttonsWrapper"
|
||||
@@ -357,4 +366,17 @@
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<!-- Biểu tượng loading xoay tròn khi đang xử lý ảnh -->
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:visibility="gone"
|
||||
android:elevation="10dp"
|
||||
android:indeterminateTint="@color/theme_primary"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
Reference in New Issue
Block a user