fix: chèn frame và lưu ảnh vào thư viện
This commit is contained in:
@@ -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)
|
||||
} 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,18 +249,146 @@ 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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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ế)
|
||||
val sat = (preset.basic.saturation + preset.basic.vibrance * 0.5f) + 1f
|
||||
val satMatrix = android.graphics.ColorMatrix().apply {
|
||||
setSaturation(sat.coerceIn(0f, 3f))
|
||||
}
|
||||
finalMatrix.postConcat(satMatrix)
|
||||
|
||||
// 2. Contrast (Tương phản thực tế)
|
||||
val scale = preset.basic.contrast + 1f
|
||||
val translate = (-0.5f * scale + 0.5f) * 255f
|
||||
val contrastMatrix = android.graphics.ColorMatrix(floatArrayOf(
|
||||
scale, 0f, 0f, 0f, translate,
|
||||
0f, scale, 0f, 0f, translate,
|
||||
0f, 0f, scale, 0f, translate,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
))
|
||||
finalMatrix.postConcat(contrastMatrix)
|
||||
|
||||
// 3. Brightness (Độ sáng Exposure thực tế)
|
||||
val brightnessOffset = preset.basic.brightness * 60f
|
||||
val brightnessMatrix = android.graphics.ColorMatrix(floatArrayOf(
|
||||
1f, 0f, 0f, 0f, brightnessOffset,
|
||||
0f, 1f, 0f, 0f, brightnessOffset,
|
||||
0f, 0f, 1f, 0f, brightnessOffset,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
))
|
||||
finalMatrix.postConcat(brightnessMatrix)
|
||||
|
||||
// 4. Temperature (Nhiệt màu thực tế: Vàng ấm / Xanh lạnh)
|
||||
val temp = preset.basic.temperature
|
||||
val rScale = 1f + temp * 0.12f
|
||||
val gScale = 1f + temp * 0.04f
|
||||
val bScale = 1f - temp * 0.12f
|
||||
val tempMatrix = android.graphics.ColorMatrix(floatArrayOf(
|
||||
rScale, 0f, 0f, 0f, 0f,
|
||||
0f, gScale, 0f, 0f, 0f,
|
||||
0f, 0f, bScale, 0f, 0f,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
))
|
||||
finalMatrix.postConcat(tempMatrix)
|
||||
|
||||
// 5. Tint (Sắc độ: Hồng dâu / Xanh lá)
|
||||
val tint = preset.basic.tint
|
||||
val rScaleTint = 1f + tint * 0.06f
|
||||
val gScaleTint = 1f - tint * 0.12f
|
||||
val bScaleTint = 1f + tint * 0.06f
|
||||
val tintMatrix = android.graphics.ColorMatrix(floatArrayOf(
|
||||
rScaleTint, 0f, 0f, 0f, 0f,
|
||||
0f, gScaleTint, 0f, 0f, 0f,
|
||||
0f, 0f, bScaleTint, 0f, 0f,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
))
|
||||
finalMatrix.postConcat(tintMatrix)
|
||||
|
||||
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")
|
||||
val filterColor = if (useCalculation) {
|
||||
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
|
||||
@@ -322,68 +451,23 @@ class MainActivity : AppCompatActivity() {
|
||||
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 = android.graphics.ColorMatrix()
|
||||
|
||||
// 1. Saturation & Vibrance (Sắc độ bão hòa màu thực tế)
|
||||
val sat = (preset.basic.saturation + preset.basic.vibrance * 0.5f) + 1f
|
||||
val satMatrix = android.graphics.ColorMatrix().apply {
|
||||
setSaturation(sat.coerceIn(0f, 3f))
|
||||
}
|
||||
finalMatrix.postConcat(satMatrix)
|
||||
|
||||
// 2. Contrast (Tương phản thực tế)
|
||||
val scale = preset.basic.contrast + 1f
|
||||
val translate = (-0.5f * scale + 0.5f) * 255f
|
||||
val contrastMatrix = android.graphics.ColorMatrix(floatArrayOf(
|
||||
scale, 0f, 0f, 0f, translate,
|
||||
0f, scale, 0f, 0f, translate,
|
||||
0f, 0f, scale, 0f, translate,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
))
|
||||
finalMatrix.postConcat(contrastMatrix)
|
||||
|
||||
// 3. Brightness (Độ sáng Exposure thực tế)
|
||||
val brightnessOffset = preset.basic.brightness * 60f
|
||||
val brightnessMatrix = android.graphics.ColorMatrix(floatArrayOf(
|
||||
1f, 0f, 0f, 0f, brightnessOffset,
|
||||
0f, 1f, 0f, 0f, brightnessOffset,
|
||||
0f, 0f, 1f, 0f, brightnessOffset,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
))
|
||||
finalMatrix.postConcat(brightnessMatrix)
|
||||
|
||||
// 4. Temperature (Nhiệt màu thực tế: Vàng ấm / Xanh lạnh)
|
||||
val temp = preset.basic.temperature
|
||||
val rScale = 1f + temp * 0.12f
|
||||
val gScale = 1f + temp * 0.04f
|
||||
val bScale = 1f - temp * 0.12f
|
||||
val tempMatrix = android.graphics.ColorMatrix(floatArrayOf(
|
||||
rScale, 0f, 0f, 0f, 0f,
|
||||
0f, gScale, 0f, 0f, 0f,
|
||||
0f, 0f, bScale, 0f, 0f,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
))
|
||||
finalMatrix.postConcat(tempMatrix)
|
||||
|
||||
// 5. Tint (Sắc độ: Hồng dâu / Xanh lá)
|
||||
val tint = preset.basic.tint
|
||||
val rScaleTint = 1f + tint * 0.06f
|
||||
val gScaleTint = 1f - tint * 0.12f
|
||||
val bScaleTint = 1f + tint * 0.06f
|
||||
val tintMatrix = android.graphics.ColorMatrix(floatArrayOf(
|
||||
rScaleTint, 0f, 0f, 0f, 0f,
|
||||
0f, gScaleTint, 0f, 0f, 0f,
|
||||
0f, 0f, bScaleTint, 0f, 0f,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
))
|
||||
finalMatrix.postConcat(tintMatrix)
|
||||
|
||||
val 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() {
|
||||
try {
|
||||
val toneG = android.media.ToneGenerator(android.media.AudioManager.STREAM_ALARM, 100)
|
||||
toneG.startTone(android.media.ToneGenerator.TONE_PROP_ACK, 250)
|
||||
} catch (e: Exception) {
|
||||
// Fallback
|
||||
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)
|
||||
}
|
||||
val successMsg = getString(R.string.toast_capture_success)
|
||||
Toast.makeText(this, successMsg, Toast.LENGTH_SHORT).show()
|
||||
|
||||
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 {
|
||||
// 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) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 598 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 494 KiB |
Reference in New Issue
Block a user