fix: chèn frame và lưu ảnh vào thư viện

This commit is contained in:
2026-07-06 11:44:11 +07:00
parent 4e3c6f35c7
commit bc3b3533a7
6 changed files with 652 additions and 91 deletions
+192
View File
@@ -0,0 +1,192 @@
# 🎨 KẾ HOẠCH NẠP FRAME INSTAX, MERGE ĐỒ HỌA & LƯU ẢNH VÀO THƯ VIỆN
Mục tiêu:
1. Hiển thị khung ảnh `instaxframe.png` đè lên kính ngắm camera thông qua Widget `Stack`.
2. Khi bấm Shutter, sử dụng thư viện `image` của Dart để hòa trộn (merge) ảnh chụp của camera vào vùng trong suốt của khung PNG dưới nền bộ lọc màu tùy chỉnh.
3. Ghi trực tiếp ảnh hoàn thiện vào thư viện máy bằng `gal` hoặc `image_gallery_saver`.
4. Gỡ bỏ hoàn toàn hiệu ứng thông báo chữ hoặc âm thanh "Tách!..." khi bấm máy.
---
## 📦 1. Bổ Sung Các Thư Viện Cần Thiết (`pubspec.yaml`)
Để xử lý việc đọc, trộn các điểm ảnh (pixel data) từ luồng phần cứng với tệp PNG và lưu vào thư viện ảnh của thiết bị, bạn hãy thêm các package sau vào file `pubspec.yaml`:
```yaml
dependencies:
flutter:
sdk: flutter
camera: ^0.10.6
path_provider: ^2.1.3 # Dùng để truy cập thư mục tạm thời
image: ^4.3.0 # Thư viện xử lý pixel: Crop, Resize, Blend màu và Merge ảnh
gal: ^2.4.0 # Thư viện lưu ảnh vào Gallery hệ thống cực kỳ gọn nhẹ
flutter:
assets:
- assets/frames/instaxframe.png
```
> 🔄 *Đừng quên chạy lệnh của Puro trong Terminal mới để cập nhật gói: `puro pub get*`
---
## 📐 2. Bố Trí Xếp Chồng Lớp Kính Ngắm (`CameraScreen.dart`)
Sử dụng cấu trúc `Stack` để lồng ghép khung ảnh cố định lên luồng preview động của camera theo đúng tỉ lệ Instax (thường là 3:4 hoặc tùy theo thiết kế file PNG của bạn).
```dart
@override
Widget build(BuildContext context) {
if (!_cameraController.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
return Scaffold(
backgroundColor: Colors.black,
body: Column(
children: [
// KHU VỰC HIỂN THỊ KÍNH NGẮM CAMERA
Expanded(
child: Center(
child: AspectRatio(
aspectRatio: 3 / 4,
child: Stack(
children: [
// Lớp 1 (Đáy): Luồng Preview từ camera phần cứng
Positioned.fill(
child: CameraPreview(_cameraController),
),
// Lớp 2 (Trên): Khung Instax PNG trong suốt
if (_isFrameApplied)
Positioned.fill(
child: Image.asset(
'assets/frames/instaxframe.png',
fit: BoxFit.contain,
),
),
],
),
),
),
),
_buildShutterContainer(), // Khu vực nút bấm chụp ở đáy
],
),
);
}
```
---
## 💻 3. Thuật Toán Hòa Trộn (Merge) Ảnh & Áp Dụng Tinh Chỉnh Màu (JSON Settings)
Khi người dùng nhấn Shutter, hệ thống sẽ chạy một luồng xử lý nền (Background Process) để ghép hai bức ảnh lại với nhau, đồng thời tính toán lại các thông số màu sắc (Brightness, Contrast, Temperature...) mà người dùng đã tinh chỉnh trên bảng sliders.
```dart
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:camera/camera.dart';
import 'package:image/image.dart' as img;
import 'package:gal/gal.dart';
Future<void> captureAndProcessImage() async {
try {
// 1. Chụp bức ảnh gốc từ luồng phần cứng Camera
final XFile rawPhoto = await _cameraController.takePicture();
// 2. Đọc dữ liệu ảnh gốc vào bộ nhớ đồ họa của thư viện 'image'
final Uint8List rawBytes = await rawPhoto.readAsBytes();
img.Image? capturedImage = img.decodeImage(rawBytes);
if (capturedImage == null) return;
// 3. ĐỒNG BỘ CÀI ĐẶT CỦA NGƯỜI DÙNG: Áp dụng bộ lọc màu từ bảng Sliders
// (Giả lập thuật toán dịch chuyển Exposure, Cân bằng trắng dựa theo JSON preset của bạn)
capturedImage = _applyUserColorSettings(capturedImage);
// 4. KIỂM TRA ĐIỀU KIỆN MERGE KHUNG HÌNH
if (_isFrameApplied) {
// Đọc file khung Instax PNG từ thư mục assets của ứng dụng
final ByteData frameData = await rootBundle.load('assets/frames/instaxframe.png');
final Uint8List frameBytes = frameData.buffer.asUint8List();
final img.Image? instaxFrame = img.decodePng(frameBytes);
if (instaxFrame != null) {
// Thay đổi kích thước ảnh chụp khớp với kích thước của khung Instax để không bị lệch pha
capturedImage = img.copyResize(capturedImage, width: instaxFrame.width, height: instaxFrame.height);
// Tiến hành ép mảnh: Vẽ đè khung Instax lên trên bức ảnh đã tinh chỉnh màu
// Phần transparent (trong suốt) của file PNG sẽ tự động hiển thị ảnh chụp bên dưới
img.compositeImage(capturedImage, instaxFrame);
}
}
// 5. NÉN ĐỒ HỌA & LƯU VÀO THƯ VIỆN THIẾT BỊ
final Uint8List finalBytes = Uint8List.fromList(img.encodeJpg(capturedImage, quality: 95));
// Tạo file tạm thời để lưu trữ dữ liệu trước khi đẩy vào Gallery
final String tempPath = '${Directory.systemTemp.path}/instax_photo_${DateTime.now().millisecondsSinceEpoch}.jpg';
final File tempFile = File(tempPath);
await tempFile.writeAsBytes(finalBytes);
// Lưu trực tiếp vào Thư viện ảnh (Photos/Gallery) của Android/iOS kèm quyền truy cập
await Gal.putImage(tempFile.path);
// Xóa file tạm sau khi đã lưu xong để giải phóng bộ nhớ máy
await tempFile.delete();
_showSuccessToast(); // Hiển thị thông báo "Đã lưu vào thư viện!"
} catch (e) {
print("Lỗi xử lý ảnh: $e");
}
}
// Hàm giả lập tính toán ma trận màu ARGB dựa theo các thông số sliders từ JSON
img.Image _applyUserColorSettings(img.Image inputImage) {
// Trích xuất các thông số thực tế thu được từ file cấu hình của bạn:
// editingPreset.basic.brightness, temperature, tint...
return img.adjustColor(
inputImage,
brightness: 1.0 + (_currentPreset.basic.brightness), // Áp độ sáng nguyên từ dải kéo
contrast: 1.0 + (_currentPreset.basic.contrast),
saturation: 1.0 + (_currentPreset.basic.saturation),
);
}
```
---
## 🔇 4. Xóa Bỏ Thông Báo "Tách!..." Khi Nhấn Nút Shutter
**Nguyên nhân:** Có thể trong code cũ của bạn đang có một hàm `ScaffoldMessenger` hiển thị SnackBar hoặc một hàm Toast kích hoạt nội dung chữ `"Tách!..."` mỗi khi hàm `takePicture()` được gọi.
**Giải pháp:** Rà soát lại sự kiện `onTap` của nút Shutter (Nút chụp lớn ở đáy màn hình). Hãy **xóa bỏ hoàn toàn** các dòng lệnh dạng hiển thị thông báo văn bản này:
```dart
// TRONG NÚT BẤM SHUTTER (CHỤP ẢNH)
GestureDetector(
onTap: () async {
// ❌ XÓA HOẶC COMMENT CÁC DÒNG THÔNG BÁO DẠNG NÀY:
// ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Tách!...")));
// showToast("Tách!...");
// Chỉ giữ lại duy nhất luồng xử lý cốt lõi một cách im lặng và chuyên nghiệp:
await captureAndProcessImage();
},
child: const ShutterButtonWidget(),
)
```
---
## 📅 5. Các Đầu Việc Cần Hoàn Thành (Checklist)
* [ ] Kiểm tra quyền (Permissions): Đảm bảo bạn đã thêm khai báo xin quyền lưu ảnh `WRITE_EXTERNAL_STORAGE` (đối với Android cũ) hoặc cấu hình trong file `AndroidManifest.xml` / `Info.plist` của iOS để tránh ứng dụng bị văng khi gọi thư viện `Gal`.
* [ ] Thực hiện test độ trễ (Performance Test): Vì quá trình xử lý pixel bằng thư viện `image` thuần Dart chạy trên CPU, hãy đảm bảo hiển thị một icon Loading xoay tròn nhỏ trong lúc máy đang xử lý merge ảnh để người dùng không bấm chụp liên tục.
* [ ] Thử nghiệm tắt/mở tính năng: Đảm bảo khi tắt "Apply Frame", ảnh chụp lưu vào máy chỉ được áp bộ lọc màu đơn thuần mà không bị đè khung hình trắng.
@@ -39,7 +39,12 @@ class FrameItemAdapter(
holder.txtFrameName.text = holder.itemView.context.getString(R.string.default_frame) holder.txtFrameName.text = holder.itemView.context.getString(R.string.default_frame)
} else { } else {
try { 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) val bitmap = BitmapFactory.decodeStream(inputStream)
holder.imgFrameThumb.setImageBitmap(bitmap) holder.imgFrameThumb.setImageBitmap(bitmap)
} catch (e: Exception) { } catch (e: Exception) {
@@ -19,6 +19,8 @@ import androidx.constraintlayout.widget.ConstraintLayout
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
import android.os.Build
import androidx.camera.core.ImageCaptureException
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
@@ -75,8 +77,7 @@ class MainActivity : AppCompatActivity() {
private fun initFrameData() { private fun initFrameData() {
allFrames = listOf( allFrames = listOf(
FrameItem("DEFAULT", "Mặc định", ""), FrameItem("DEFAULT", "Mặc định", ""),
FrameItem("instax_mini_single", "Instax Mini", "frames/instax_mini_single.png"), FrameItem("instax_mini_single", "Instax Mini", "frames/instaxframe.png")
FrameItem("photobooth_4strip", "Photobooth 4-Strip", "frames/photobooth_4strip.png")
) )
// Initialize default Color Presets // Initialize default Color Presets
@@ -248,86 +249,82 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(this, errorMsg, Toast.LENGTH_SHORT).show() Toast.makeText(this, errorMsg, Toast.LENGTH_SHORT).show()
} }
} }
adjustViewFinderBounds()
} }
private fun applyPresetFilter(preset: ColorPreset) { private fun adjustViewFinderBounds() {
currentSelectedPreset = preset val framePath = currentSelectedFramePath
if (preset.id == "DEFAULT") { if (framePath == null || framePath.isEmpty()) {
binding.imgFilterOverlay.visibility = android.view.View.GONE // Reset viewFinder to match_parent
binding.viewFinder.setLayerType(android.view.View.LAYER_TYPE_NONE, null) 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 { } else {
binding.imgFilterOverlay.visibility = android.view.View.VISIBLE binding.cameraContainer.post {
val isBeingEdited = editingPreset != null val containerWidth = binding.cameraContainer.width
val useCalculation = isBeingEdited || (preset.id != "instax_faded_warm_01" && preset.id != "instax_bw_cool" && preset.id != "instax_cool_summer") val containerHeight = binding.cameraContainer.height
val filterColor = if (useCalculation) { if (containerWidth > 0 && containerHeight > 0) {
// 1. ALPHA (Độ đậm/Mờ của lớp filter): Được quyết định bởi Saturation, Vibrance và hiệu ứng nâng cao // We know instaxframe is 1024 x 1207
// 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 imgW = 1024f
val baseAlpha = 60f val imgH = 1207f
val saturationImpact = (preset.basic.saturation + preset.basic.vibrance) * 25f val aspect = imgW / imgH
val contrastImpact = preset.basic.contrast * 15f
val alpha = (baseAlpha + saturationImpact + contrastImpact).toInt().coerceIn(15, 140) val containerAspect = containerWidth.toFloat() / containerHeight.toFloat()
// 2. BASE COLOR (Màu nền mặc định ban đầu trước khi tinh chỉnh) var targetWidth = containerWidth
// 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 targetHeight = containerHeight
var baseR = 140f
var baseG = 130f if (containerAspect > aspect) {
var baseB = 120f // Container is wider than the image aspect -> height is matched, width is scaled
targetWidth = (containerHeight * aspect).toInt()
// 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 } else {
val brightnessOffset = preset.basic.brightness * 80f // Nhân hệ số để biên độ thay đổi từ -100 đến +100 rõ rệt // Container is taller than the image aspect -> width is matched, height is scaled
baseR += brightnessOffset targetHeight = (containerWidth / aspect).toInt()
baseG += brightnessOffset }
baseB += brightnessOffset
val verticalMargin = (containerHeight - targetHeight) / 2
// 4. TEMPERATURE (Nhiệt màu): + là Ấm (Tăng Đỏ, Giảm Lam), - là Lạnh (Giảm Đỏ, Tăng Lam) val horizontalMargin = (containerWidth - targetWidth) / 2
val tempOffset = preset.basic.temperature * 50f
baseR += tempOffset // Adjust viewFinder params
baseB -= tempOffset val params = binding.viewFinder.layoutParams as android.widget.FrameLayout.LayoutParams
params.width = targetWidth
// 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) params.height = targetHeight
val tintOffset = preset.basic.tint * 40f params.topMargin = verticalMargin
baseG -= tintOffset params.bottomMargin = verticalMargin
baseR += (tintOffset * 0.5f) params.leftMargin = horizontalMargin
baseB += (tintOffset * 0.5f) params.rightMargin = horizontalMargin
binding.viewFinder.layoutParams = params
// 6. TONE CURVE & SHADOWS TINT (Ám màu vùng tối đặc trưng của Instax)
baseR += (preset.toneCurve.shadowsTintR * 35f) // Adjust filter overlay params
baseG += (preset.toneCurve.shadowsTintG * 35f) val filterParams = binding.imgFilterOverlay.layoutParams as android.widget.FrameLayout.LayoutParams
baseB += (preset.toneCurve.shadowsTintB * 35f) filterParams.width = targetWidth
filterParams.height = targetHeight
// Nâng đáy màu đen (Faded Black) tạo độ sờn ảnh filterParams.topMargin = verticalMargin
val fadeOffset = preset.toneCurve.fadedBlackLevel * 30f filterParams.bottomMargin = verticalMargin
baseR += fadeOffset filterParams.leftMargin = horizontalMargin
baseG += fadeOffset filterParams.rightMargin = horizontalMargin
baseB += fadeOffset binding.imgFilterOverlay.layoutParams = filterParams
// 7. CLARITY & DEHAZE (Điều tiết thêm độ tương phản cục bộ của màu)
val advancedOffset = (preset.advanced.clarity - preset.advanced.dehaze) * 20f
baseR += advancedOffset
baseG += advancedOffset
baseB += advancedOffset
// 8. ÉP BIÊN ĐỘ (COERCE) để đảm bảo giá trị RGB luôn nằm trong giới hạn vật lý 0 -> 255
val r = baseR.toInt().coerceIn(0, 255)
val g = baseG.toInt().coerceIn(0, 255)
val b = baseB.toInt().coerceIn(0, 255)
android.graphics.Color.argb(alpha, r, g, b)
} else {
// Khối xử lý cho các mẫu mặc định cố định không sửa đổi
when (preset.id) {
"instax_faded_warm_01" -> android.graphics.Color.argb(55, 255, 152, 0)
"instax_bw_cool" -> android.graphics.Color.argb(80, 128, 128, 128)
"instax_cool_summer" -> android.graphics.Color.argb(45, 0, 188, 212)
else -> android.graphics.Color.argb(0, 0, 0, 0)
} }
} }
binding.imgFilterOverlay.setBackgroundColor(filterColor)
applyColorMatrixToViewFinder(preset)
} }
} }
private fun applyColorMatrixToViewFinder(preset: ColorPreset) { private fun getColorMatrixForPreset(preset: ColorPreset): android.graphics.ColorMatrix {
val finalMatrix = android.graphics.ColorMatrix() val finalMatrix = android.graphics.ColorMatrix()
// 1. Saturation & Vibrance (Sắc độ bão hòa màu thực tế) // 1. Saturation & Vibrance (Sắc độ bão hòa màu thực tế)
@@ -384,6 +381,93 @@ class MainActivity : AppCompatActivity() {
)) ))
finalMatrix.postConcat(tintMatrix) 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 { val paint = android.graphics.Paint().apply {
colorFilter = android.graphics.ColorMatrixColorFilter(finalMatrix) colorFilter = android.graphics.ColorMatrixColorFilter(finalMatrix)
} }
@@ -507,6 +591,7 @@ class MainActivity : AppCompatActivity() {
if (isCountingDown) return if (isCountingDown) return
val wrapper = binding.buttonsWrapper val wrapper = binding.buttonsWrapper
TransitionManager.beginDelayedTransition(wrapper) TransitionManager.beginDelayedTransition(wrapper)
val density = resources.displayMetrics.density
if (!isFrameModeOpen) { if (!isFrameModeOpen) {
isFrameModeOpen = true isFrameModeOpen = true
@@ -515,14 +600,16 @@ class MainActivity : AppCompatActivity() {
// Hide preset button // Hide preset button
binding.btnMainPreset.visibility = android.view.View.GONE 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 val params = binding.btnMainFrame.layoutParams as ConstraintLayout.LayoutParams
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.endToStart = ConstraintLayout.LayoutParams.UNSET params.endToStart = ConstraintLayout.LayoutParams.UNSET
params.endToEnd = ConstraintLayout.LayoutParams.UNSET params.endToEnd = ConstraintLayout.LayoutParams.UNSET
params.leftMargin = (12.5f * density).toInt()
binding.btnMainFrame.layoutParams = params 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.visibility = android.view.View.VISIBLE
binding.rvHorizontalTimeline.adapter = frameItemAdapter binding.rvHorizontalTimeline.adapter = frameItemAdapter
} else { } else {
@@ -533,12 +620,12 @@ class MainActivity : AppCompatActivity() {
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.endToStart = binding.btnMainPreset.id params.endToStart = binding.btnMainPreset.id
params.endToEnd = ConstraintLayout.LayoutParams.UNSET params.endToEnd = ConstraintLayout.LayoutParams.UNSET
params.leftMargin = 0
binding.btnMainFrame.layoutParams = params binding.btnMainFrame.layoutParams = params
// Show preset button // Show preset button & hide timeline/dock background
binding.btnMainPreset.visibility = android.view.View.VISIBLE binding.btnMainPreset.visibility = android.view.View.VISIBLE
binding.viewLeftDockBackground.visibility = android.view.View.GONE
// Hide timeline
binding.rvHorizontalTimeline.visibility = android.view.View.GONE binding.rvHorizontalTimeline.visibility = android.view.View.GONE
} }
} }
@@ -547,6 +634,7 @@ class MainActivity : AppCompatActivity() {
if (isCountingDown) return if (isCountingDown) return
val wrapper = binding.buttonsWrapper val wrapper = binding.buttonsWrapper
TransitionManager.beginDelayedTransition(wrapper) TransitionManager.beginDelayedTransition(wrapper)
val density = resources.displayMetrics.density
if (!isPresetModeOpen) { if (!isPresetModeOpen) {
isPresetModeOpen = true isPresetModeOpen = true
@@ -555,14 +643,16 @@ class MainActivity : AppCompatActivity() {
// Hide frame button // Hide frame button
binding.btnMainFrame.visibility = android.view.View.GONE 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 val params = binding.btnMainPreset.layoutParams as ConstraintLayout.LayoutParams
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.startToEnd = ConstraintLayout.LayoutParams.UNSET params.startToEnd = ConstraintLayout.LayoutParams.UNSET
params.endToEnd = ConstraintLayout.LayoutParams.UNSET params.endToEnd = ConstraintLayout.LayoutParams.UNSET
params.leftMargin = (12.5f * density).toInt()
binding.btnMainPreset.layoutParams = params 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 binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE
presetAdapter.resetSelection() presetAdapter.resetSelection()
binding.rvHorizontalTimeline.adapter = presetAdapter binding.rvHorizontalTimeline.adapter = presetAdapter
@@ -574,12 +664,12 @@ class MainActivity : AppCompatActivity() {
params.startToStart = ConstraintLayout.LayoutParams.UNSET params.startToStart = ConstraintLayout.LayoutParams.UNSET
params.startToEnd = binding.btnMainFrame.id params.startToEnd = binding.btnMainFrame.id
params.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID params.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
params.leftMargin = 0
binding.btnMainPreset.layoutParams = params binding.btnMainPreset.layoutParams = params
// Show frame button // Show frame button & hide timeline/dock background
binding.btnMainFrame.visibility = android.view.View.VISIBLE binding.btnMainFrame.visibility = android.view.View.VISIBLE
binding.viewLeftDockBackground.visibility = android.view.View.GONE
// Hide timeline
binding.rvHorizontalTimeline.visibility = android.view.View.GONE binding.rvHorizontalTimeline.visibility = android.view.View.GONE
} }
} }
@@ -962,14 +1052,262 @@ class MainActivity : AppCompatActivity() {
} }
private fun takePicture() { 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 { try {
val toneG = android.media.ToneGenerator(android.media.AudioManager.STREAM_ALARM, 100) // 1. Decode captured file to Bitmap
toneG.startTone(android.media.ToneGenerator.TONE_PROP_ACK, 250) 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) { } 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 { private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
@@ -999,6 +1337,10 @@ class MainActivity : AppCompatActivity() {
companion object { companion object {
private const val REQUEST_CODE_PERMISSIONS = 10 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:id="@+id/cameraContainer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:background="#000000"
app:layout_constraintDimensionRatio="3:4" app:layout_constraintDimensionRatio="3:4"
app:layout_constraintTop_toBottomOf="@id/topBar" app:layout_constraintTop_toBottomOf="@id/topBar"
app:layout_constraintBottom_toTopOf="@id/panelSelectionContainer"> app:layout_constraintBottom_toTopOf="@id/panelSelectionContainer">
@@ -149,6 +150,14 @@
android:paddingEnd="16dp" android:paddingEnd="16dp"
android:visibility="gone" /> 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) --> <!-- 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 <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/buttonsWrapper" android:id="@+id/buttonsWrapper"
@@ -357,4 +366,17 @@
</androidx.constraintlayout.widget.ConstraintLayout> </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> </androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB