feat: thêm tính năng tải frame, presets, background từ cloud

This commit is contained in:
2026-07-07 07:57:02 +07:00
parent 9af4498897
commit cdb9e450b5
12 changed files with 1004 additions and 103 deletions
@@ -17,7 +17,8 @@
android:icon="@android:mipmap/sym_def_app_icon"
android:label="Retro Photobooth"
android:supportsRtl="true"
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar">
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
@@ -41,10 +41,18 @@ class BackgroundOptionAdapter(
if (item.assetPath == null) {
holder.imgBgThumb.setImageResource(R.drawable.ic_block_white)
} else if (item.id == "MORE") {
holder.imgBgThumb.setImageResource(R.drawable.ic_more)
holder.txtBgName.text = "Tải thêm"
} else {
try {
val inputStream = holder.itemView.context.assets.open(item.assetPath)
val bitmap = BitmapFactory.decodeStream(inputStream)
val localFile = java.io.File(holder.itemView.context.filesDir, item.assetPath)
val bitmap = if (localFile.exists()) {
BitmapFactory.decodeFile(localFile.absolutePath)
} else {
val inputStream = holder.itemView.context.assets.open(item.assetPath)
BitmapFactory.decodeStream(inputStream)
}
holder.imgBgThumb.setImageBitmap(bitmap)
} catch (e: Exception) {
holder.imgBgThumb.setImageDrawable(null)
@@ -71,4 +79,14 @@ class BackgroundOptionAdapter(
}
override fun getItemCount(): Int = items.size
fun selectItem(itemId: String) {
val index = items.indexOfFirst { it.id == itemId }
if (index != -1 && index != selectedPosition) {
val oldPos = selectedPosition
selectedPosition = index
notifyItemChanged(oldPos)
notifyItemChanged(selectedPosition)
}
}
}
@@ -37,15 +37,23 @@ class FrameItemAdapter(
if (item.id == "DEFAULT") {
holder.imgFrameThumb.setImageResource(R.drawable.ic_block_white)
holder.txtFrameName.text = holder.itemView.context.getString(R.string.default_frame)
} else if (item.id == "MORE") {
holder.imgFrameThumb.setImageResource(R.drawable.ic_more)
holder.txtFrameName.text = "Tải thêm"
} else {
try {
val path = if (item.imageFileName.startsWith("frames/")) {
item.imageFileName
val localFile = java.io.File(holder.itemView.context.filesDir, item.imageFileName)
val bitmap = if (localFile.exists()) {
BitmapFactory.decodeFile(localFile.absolutePath)
} else {
"frames/${item.imageFileName}"
val path = if (item.imageFileName.startsWith("frames/")) {
item.imageFileName
} else {
"frames/${item.imageFileName}"
}
val inputStream = holder.itemView.context.assets.open(path)
BitmapFactory.decodeStream(inputStream)
}
val inputStream = holder.itemView.context.assets.open(path)
val bitmap = BitmapFactory.decodeStream(inputStream)
holder.imgFrameThumb.setImageBitmap(bitmap)
} catch (e: Exception) {
holder.imgFrameThumb.setImageDrawable(null)
@@ -78,4 +86,14 @@ class FrameItemAdapter(
selectedPosition = 0 // Reset to default when switching stack
notifyDataSetChanged()
}
fun selectItem(itemId: String) {
val index = items.indexOfFirst { it.id == itemId }
if (index != -1 && index != selectedPosition) {
val oldPos = selectedPosition
selectedPosition = index
notifyItemChanged(oldPos)
notifyItemChanged(selectedPosition)
}
}
}
@@ -88,11 +88,17 @@ class MainActivity : AppCompatActivity() {
private var currentBackgroundMode = BackgroundMode.DEFAULT
private fun updateBackgroundMode() {
val oldMode = currentBackgroundMode
currentBackgroundMode = when {
selectedBgAssetPath != null || blurIntensity > 0f -> BackgroundMode.AI_BLUR
isPortraitBlurModeOpen -> BackgroundMode.HARDWARE_DOF
else -> BackgroundMode.DEFAULT
}
if (currentBackgroundMode == BackgroundMode.AI_BLUR && oldMode != BackgroundMode.AI_BLUR) {
// Tắt focus API: Hủy bỏ lấy nét thấu kính vật lý và reset class focus
userSelectedClassFocus = -1
cameraControl?.cancelFocusAndMetering()
}
}
private fun getImageSegmenter(): ImageSegmenter? {
@@ -162,7 +168,7 @@ class MainActivity : AppCompatActivity() {
allFrames = listOf(
FrameItem("DEFAULT", "Mặc định", ""),
FrameItem("instax_mini_single", "Instax Mini", "frames/instaxframe.png")
)
) + loadDownloadedFrames() + listOf(FrameItem("MORE", "Tải thêm", ""))
// Initialize default Color Presets
val defaultPresets = listOf(
@@ -209,7 +215,18 @@ class MainActivity : AppCompatActivity() {
)
// Load custom user saved presets from filesDir
colorPresets = defaultPresets + loadCustomPresets()
colorPresets = defaultPresets + loadCustomPresets() + listOf(
ColorPreset(
id = "MORE",
name = "Tải thêm",
themeCategory = "None",
isEditable = false,
basic = BasicAdjustments(0f, 0f, 0f, 0f, 0f, 0f),
advanced = AdvancedEffects(0f, 0f, 0f),
toneCurve = ToneCurveEmulation(0f, 0f, 0f, 0f),
grain = FilmGrain(0f, 0f)
)
)
}
private fun loadCustomPresets(): List<ColorPreset> {
@@ -276,13 +293,21 @@ class MainActivity : AppCompatActivity() {
binding.rvHorizontalTimeline.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
frameItemAdapter = FrameItemAdapter(allFrames) { frame ->
setFrameOverlay(frame.imageFileName)
updateFrameModeIcon(frame)
if (frame.id == "MORE") {
openOnlineStoreDialog("frames")
} else {
setFrameOverlay(frame.imageFileName)
updateFrameModeIcon(frame)
}
}
presetAdapter = PresetAdapter(colorPresets, { preset ->
applyPresetFilter(preset)
updatePresetModeIcon(preset)
if (preset.id == "MORE") {
openOnlineStoreDialog("presets")
} else {
applyPresetFilter(preset)
updatePresetModeIcon(preset)
}
}, { preset ->
openAdvancedSliders(preset)
})
@@ -323,10 +348,14 @@ class MainActivity : AppCompatActivity() {
BackgroundOption("BG_01", "Nền 1", "backgrounds/background-01.png"),
BackgroundOption("BG_02", "Nền 2", "backgrounds/background-02.png"),
BackgroundOption("BG_03", "Nền 3", "backgrounds/background-03.png")
)
) + loadDownloadedBackgrounds() + listOf(BackgroundOption("MORE", "Tải thêm", "MORE"))
backgroundOptionAdapter = BackgroundOptionAdapter(backgroundOptions) { option ->
selectedBgAssetPath = option.assetPath
updateBackgroundMode()
if (option.id == "MORE") {
openOnlineStoreDialog("backgrounds")
} else {
selectedBgAssetPath = option.assetPath
updateBackgroundMode()
}
}
binding.rvBackgroundOptions.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
binding.rvBackgroundOptions.adapter = backgroundOptionAdapter
@@ -352,8 +381,13 @@ class MainActivity : AppCompatActivity() {
currentSelectedFramePath = null
} else {
try {
val inputStream = assets.open(fileName)
val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
val localFile = java.io.File(filesDir, fileName)
val bitmap = if (localFile.exists()) {
android.graphics.BitmapFactory.decodeFile(localFile.absolutePath)
} else {
val inputStream = assets.open(fileName)
android.graphics.BitmapFactory.decodeStream(inputStream)
}
binding.imgFrameOverlay.setImageBitmap(bitmap)
currentSelectedFramePath = fileName
} catch (e: Exception) {
@@ -747,18 +781,17 @@ class MainActivity : AppCompatActivity() {
val percentX = (event.x / v.width).coerceIn(0f, 1f)
val percentY = (event.y / v.height).coerceIn(0f, 1f)
// Cập nhật Class ID lấy nét từ điểm chạm
val mask = cachedCategoryMask
val mW = cachedMaskWidth
val mH = cachedMaskHeight
if (mask != null && mW > 0 && mH > 0) {
val maskX = (percentX * mW).toInt().coerceIn(0, mW - 1)
val maskY = (percentY * mH).toInt().coerceIn(0, mH - 1)
userSelectedClassFocus = mask[maskY * mW + maskX].toInt()
}
// 1. Gửi lệnh Focus vật lý và đo sáng qua CameraX API (chỉ khi không sử dụng tính năng AI tách/xóa nền)
// Cập nhật Class ID lấy nét từ điểm chạm (chỉ khi không sử dụng tính năng AI tách/xóa nền)
if (currentBackgroundMode != BackgroundMode.AI_BLUR) {
val mask = cachedCategoryMask
val mW = cachedMaskWidth
val mH = cachedMaskHeight
if (mask != null && mW > 0 && mH > 0) {
val maskX = (percentX * mW).toInt().coerceIn(0, mW - 1)
val maskY = (percentY * mH).toInt().coerceIn(0, mH - 1)
userSelectedClassFocus = mask[maskY * mW + maskX].toInt()
}
val cameraCtrl = this@MainActivity.cameraControl
if (cameraCtrl != null) {
try {
@@ -787,6 +820,10 @@ class MainActivity : AppCompatActivity() {
.setDuration(900)
.withEndAction { focusRing.visibility = android.view.View.GONE }
.start()
} else {
userSelectedClassFocus = -1
// Đảm bảo hủy bỏ lấy nét thấu kính vật lý để camera tự động lấy nét toàn cảnh rộng (AF liên tục)
cameraControl?.cancelFocusAndMetering()
}
v.performClick()
@@ -1287,6 +1324,11 @@ class MainActivity : AppCompatActivity() {
cameraControl = camera.cameraControl
cameraInfo = camera.cameraInfo
if (currentBackgroundMode == BackgroundMode.AI_BLUR) {
userSelectedClassFocus = -1
cameraControl?.cancelFocusAndMetering()
}
// Reset zoom to 1.0x on start/switch
applyZoom(1.0f)
} catch (xc: Exception) {
@@ -1770,40 +1812,17 @@ class MainActivity : AppCompatActivity() {
cachedMaskHeight = h
categoryMaskBuffer.rewind()
// Create and blur alpha mask with Touch-to-Focus class selection
val focusClassId = if (userSelectedClassFocus >= 0) userSelectedClassFocus else -1
// Create and blur alpha mask (keep entire person sharp: all classes > 0)
val alphaMask = FloatArray(w * h)
for (i in 0 until (w * h)) {
val classId = if (i < bufferCapacity) rawBytes[i].toInt() else 0
alphaMask[i] = if (focusClassId >= 0) {
// Người dùng đã chạm chọn: chỉ lớp đúng class ID mới nét (Semantic Focus Plane)
if (classId == focusClassId) 1.0f else 0.0f
} else {
// Mặc định: Giữ nét toàn bộ Người (ID 1) và Phụ kiện cận cảnh (ID 2)
if (classId == 1 || classId == 2) 1.0f else 0.0f
}
alphaMask[i] = if (classId > 0) 1.0f else 0.0f
}
blurAlphaMaskBox(alphaMask, w, h, radius = 3)
// Prepare background (camera preview or custom background asset)
val baseBg = if (selectedBgAssetPath != null) {
try {
val bgInputStream = assets.open(selectedBgAssetPath!!)
val decodedBg = android.graphics.BitmapFactory.decodeStream(bgInputStream)
if (decodedBg != null) {
val tempScaled = android.graphics.Bitmap.createScaledBitmap(decodedBg, w, h, true)
if (tempScaled != decodedBg) decodedBg.recycle()
tempScaled
} else {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
} catch (e: Exception) {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
loadBackgroundBitmap(selectedBgAssetPath!!, w, h)
} else {
capturedImage
}
@@ -1885,40 +1904,17 @@ class MainActivity : AppCompatActivity() {
val h = categoryMask.height
val bufferCapacity = categoryMaskBuffer.capacity()
// Create and blur alpha mask with Touch-to-Focus class selection
val focusClassId = if (userSelectedClassFocus >= 0) userSelectedClassFocus else -1
// Create and blur alpha mask (keep entire person sharp: all classes > 0)
val alphaMask = FloatArray(w * h)
for (i in 0 until (w * h)) {
val classId = if (i < bufferCapacity) categoryMaskBuffer.get(i).toInt() else 0
alphaMask[i] = if (focusClassId >= 0) {
// Người dùng đã chạm chọn: chỉ lớp đúng class ID mới nét (Semantic Focus Plane)
if (classId == focusClassId) 1.0f else 0.0f
} else {
// Mặc định: Giữ nét toàn bộ Người (ID 1) và Phụ kiện cận cảnh (ID 2)
if (classId == 1 || classId == 2) 1.0f else 0.0f
}
alphaMask[i] = if (classId > 0) 1.0f else 0.0f
}
blurAlphaMaskBox(alphaMask, w, h, radius = 5)
// Prepare background (camera preview or custom background asset)
val baseBg = if (selectedBgAssetPath != null) {
try {
val bgInputStream = assets.open(selectedBgAssetPath!!)
val decodedBg = android.graphics.BitmapFactory.decodeStream(bgInputStream)
if (decodedBg != null) {
val tempScaled = android.graphics.Bitmap.createScaledBitmap(decodedBg, w, h, true)
if (tempScaled != decodedBg) decodedBg.recycle()
tempScaled
} else {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
} catch (e: Exception) {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
loadBackgroundBitmap(selectedBgAssetPath!!, w, h)
} else {
// Triệt tiêu Ghost Shadow (Bóng ma rế viền) bằng giãn cơ thể ẩn (Inpainting Dilate)
val cleanBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
@@ -2317,11 +2313,11 @@ class MainActivity : AppCompatActivity() {
val h = capturedImage.height
val alphaMask = FloatArray(w * h)
// Giả lập mặt nạ bằng hình bầu dục lấy nét mềm ở trung tâm (mô phỏng chân dung)
// Giả lập mặt nạ bằng hình bầu dục lấy nét mềm ở trung tâm (Tối ưu hóa đẩy tâm lên cao và mở rộng bán kính để giữ nét hoàn toàn cho khuôn mặt và đầu tóc người chụp)
val cx = w * 0.5f
val cy = h * 0.52f
val rx = w * 0.28f
val ry = h * 0.38f
val cy = h * 0.40f
val rx = w * 0.40f
val ry = h * 0.50f
for (y in 0 until h) {
val dy = (y - cy) / ry
val rowOffset = y * w
@@ -2339,23 +2335,7 @@ class MainActivity : AppCompatActivity() {
// TẠO HÌNH NỀN (Background Canvas)
val baseBg = if (selectedBgAssetPath != null) {
try {
val bgInputStream = assets.open(selectedBgAssetPath!!)
val decodedBg = android.graphics.BitmapFactory.decodeStream(bgInputStream)
if (decodedBg != null) {
val tempScaled = android.graphics.Bitmap.createScaledBitmap(decodedBg, w, h, true)
if (tempScaled != decodedBg) decodedBg.recycle()
tempScaled
} else {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
} catch (e: Exception) {
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
solidBg
}
loadBackgroundBitmap(selectedBgAssetPath!!, w, h)
} else {
// Triệt tiêu Ghost Shadow bằng Inpainting Dilate
val cleanBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
@@ -2428,6 +2408,372 @@ class MainActivity : AppCompatActivity() {
return outputBitmap
}
private fun loadBackgroundBitmap(path: String, w: Int, h: Int): android.graphics.Bitmap {
try {
val localFile = java.io.File(filesDir, path)
val decodedBg = if (localFile.exists()) {
android.graphics.BitmapFactory.decodeFile(localFile.absolutePath)
} else {
val bgInputStream = assets.open(path)
android.graphics.BitmapFactory.decodeStream(bgInputStream)
}
if (decodedBg != null) {
val tempScaled = android.graphics.Bitmap.createScaledBitmap(decodedBg, w, h, true)
if (tempScaled != decodedBg) decodedBg.recycle()
return tempScaled
}
} catch (e: Exception) {
e.printStackTrace()
}
val solidBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
solidBg.eraseColor(android.graphics.Color.BLACK)
return solidBg
}
private fun loadDownloadedFrames(): List<FrameItem> {
val list = mutableListOf<FrameItem>()
val dir = java.io.File(filesDir, "downloaded_frames")
if (dir.exists() && dir.isDirectory) {
dir.listFiles()?.forEach { file ->
if (file.isFile && file.name.endsWith(".png")) {
val id = file.nameWithoutExtension
val name = id.replace("_", " ").replaceFirstChar { if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() }
list.add(FrameItem(id, name, "downloaded_frames/${file.name}"))
}
}
}
return list
}
private fun loadDownloadedBackgrounds(): List<BackgroundOption> {
val list = mutableListOf<BackgroundOption>()
val dir = java.io.File(filesDir, "downloaded_backgrounds")
if (dir.exists() && dir.isDirectory) {
dir.listFiles()?.forEach { file ->
if (file.isFile && (file.name.endsWith(".jpg") || file.name.endsWith(".png") || file.name.endsWith(".jpeg"))) {
val id = file.nameWithoutExtension
val name = id.replace("_", " ").replaceFirstChar { if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() }
list.add(BackgroundOption(id, name, "downloaded_backgrounds/${file.name}"))
}
}
}
return list
}
private var storeDialog: androidx.appcompat.app.AlertDialog? = null
private fun getServerUrl(): String {
val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
return prefs.getString("server_url", "http://10.0.2.2:8080") ?: "http://10.0.2.2:8080"
}
private fun saveServerUrl(url: String) {
val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
prefs.edit().putString("server_url", url).apply()
}
private fun openOnlineStoreDialog(defaultTab: String = "frames") {
val dialogView = layoutInflater.inflate(R.layout.dialog_online_store, null)
val etServerUrl = dialogView.findViewById<android.widget.EditText>(R.id.etServerUrl)
val btnConnectServer = dialogView.findViewById<android.widget.Button>(R.id.btnConnectServer)
val btnTabFrames = dialogView.findViewById<android.widget.Button>(R.id.btnTabFrames)
val btnTabBackgrounds = dialogView.findViewById<android.widget.Button>(R.id.btnTabBackgrounds)
val btnTabPresets = dialogView.findViewById<android.widget.Button>(R.id.btnTabPresets)
val rvStoreAssets = dialogView.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.rvStoreAssets)
val progressLoadingStore = dialogView.findViewById<android.widget.ProgressBar>(R.id.progressLoadingStore)
val layoutStoreError = dialogView.findViewById<android.widget.LinearLayout>(R.id.layoutStoreError)
val btnStoreRetry = dialogView.findViewById<android.widget.Button>(R.id.btnStoreRetry)
val btnStoreClose = dialogView.findViewById<android.widget.ImageButton>(R.id.btnStoreClose)
etServerUrl.setText(getServerUrl())
var currentTab = defaultTab
var allFetchedItems = listOf<OnlineStoreItem>()
val storeAdapter = StoreItemAdapter(listOf(), filesDir) { storeItem ->
if (storeItem.downloadState == DownloadState.DOWNLOADED) {
applyDownloadedAsset(storeItem)
storeDialog?.dismiss()
} else if (storeItem.downloadState == DownloadState.NOT_DOWNLOADED) {
downloadAsset(storeItem, rvStoreAssets.adapter as StoreItemAdapter)
}
}
rvStoreAssets.layoutManager = LinearLayoutManager(this)
rvStoreAssets.adapter = storeAdapter
fun updateTabButtons() {
val primaryColor = ContextCompat.getColor(this, R.color.theme_primary)
val cardColor = ContextCompat.getColor(this, R.color.theme_item_card_bg)
val darkColor = ContextCompat.getColor(this, R.color.theme_panel_dark)
val whiteColor = ContextCompat.getColor(this, R.color.theme_white)
btnTabFrames.setBackgroundColor(if (currentTab == "frames") primaryColor else cardColor)
btnTabFrames.setTextColor(if (currentTab == "frames") darkColor else whiteColor)
btnTabBackgrounds.setBackgroundColor(if (currentTab == "backgrounds") primaryColor else cardColor)
btnTabBackgrounds.setTextColor(if (currentTab == "backgrounds") darkColor else whiteColor)
btnTabPresets.setBackgroundColor(if (currentTab == "presets") primaryColor else cardColor)
btnTabPresets.setTextColor(if (currentTab == "presets") darkColor else whiteColor)
val filtered = allFetchedItems.filter { it.category == currentTab }
storeAdapter.updateItems(filtered)
}
fun fetchManifest() {
val urlStr = etServerUrl.text.toString().trim()
if (urlStr.isEmpty()) {
Toast.makeText(this, "Vui lòng nhập địa chỉ server", Toast.LENGTH_SHORT).show()
return
}
saveServerUrl(urlStr)
progressLoadingStore.visibility = android.view.View.VISIBLE
rvStoreAssets.visibility = android.view.View.GONE
layoutStoreError.visibility = android.view.View.GONE
Thread {
try {
val url = java.net.URL("$urlStr/manifest.json")
val connection = url.openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 5000
connection.readTimeout = 5000
val content = connection.inputStream.bufferedReader().use { it.readText() }
val json = org.json.JSONObject(content)
val itemsList = mutableListOf<OnlineStoreItem>()
if (json.has("frames")) {
val arr = json.getJSONArray("frames")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
itemsList.add(OnlineStoreItem(
id = obj.getString("id"),
name = obj.getString("name"),
url = obj.getString("url"),
version = obj.optInt("version", 1),
category = "frames"
))
}
}
if (json.has("backgrounds")) {
val arr = json.getJSONArray("backgrounds")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
itemsList.add(OnlineStoreItem(
id = obj.getString("id"),
name = obj.getString("name"),
url = obj.getString("url"),
version = obj.optInt("version", 1),
category = "backgrounds"
))
}
}
if (json.has("presets")) {
val arr = json.getJSONArray("presets")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
itemsList.add(OnlineStoreItem(
id = obj.getString("id"),
name = obj.getString("name"),
url = obj.getString("url"),
version = obj.optInt("version", 1),
category = "presets"
))
}
}
runOnUiThread {
allFetchedItems = itemsList
progressLoadingStore.visibility = android.view.View.GONE
rvStoreAssets.visibility = android.view.View.VISIBLE
updateTabButtons()
}
} catch (e: Exception) {
e.printStackTrace()
runOnUiThread {
progressLoadingStore.visibility = android.view.View.GONE
layoutStoreError.visibility = android.view.View.VISIBLE
Toast.makeText(this, "Vui lòng kết nối mạng để tải thêm tài nguyên", Toast.LENGTH_LONG).show()
}
}
}.start()
}
btnConnectServer.setOnClickListener { fetchManifest() }
btnStoreRetry.setOnClickListener { fetchManifest() }
btnTabFrames.setOnClickListener {
currentTab = "frames"
updateTabButtons()
}
btnTabBackgrounds.setOnClickListener {
currentTab = "backgrounds"
updateTabButtons()
}
btnTabPresets.setOnClickListener {
currentTab = "presets"
updateTabButtons()
}
btnStoreClose.setOnClickListener { storeDialog?.dismiss() }
fetchManifest()
storeDialog = androidx.appcompat.app.AlertDialog.Builder(this)
.setView(dialogView)
.create()
storeDialog?.window?.setBackgroundDrawableResource(android.R.color.transparent)
storeDialog?.show()
}
private fun downloadAsset(item: OnlineStoreItem, adapter: StoreItemAdapter) {
item.downloadState = DownloadState.DOWNLOADING
adapter.notifyDataSetChanged()
Thread {
try {
val url = java.net.URL(item.url)
val targetDir = when (item.category) {
"frames" -> java.io.File(filesDir, "downloaded_frames")
"backgrounds" -> java.io.File(filesDir, "downloaded_backgrounds")
else -> filesDir
}
if (!targetDir.exists()) targetDir.mkdirs()
val ext = item.url.substringAfterLast(".", "png")
val fileName = if (item.category == "presets") "${item.id}.json" else "${item.id}.$ext"
val outputFile = java.io.File(targetDir, fileName)
url.openStream().use { input ->
outputFile.outputStream().use { output ->
input.copyTo(output)
}
}
runOnUiThread {
item.downloadState = DownloadState.DOWNLOADED
adapter.notifyDataSetChanged()
Toast.makeText(this, "Tải thành công: ${item.name}", Toast.LENGTH_SHORT).show()
reloadLocalAssets()
}
} catch (e: Exception) {
e.printStackTrace()
runOnUiThread {
item.downloadState = DownloadState.NOT_DOWNLOADED
adapter.notifyDataSetChanged()
Toast.makeText(this, "Tải thất bại: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}.start()
}
private fun reloadLocalAssets() {
allFrames = listOf(
FrameItem("DEFAULT", "Mặc định", ""),
FrameItem("instax_mini_single", "Instax Mini", "frames/instaxframe.png")
) + loadDownloadedFrames() + listOf(FrameItem("MORE", "Tải thêm", ""))
frameItemAdapter.updateItems(allFrames)
backgroundOptions = listOf(
BackgroundOption("NONE", "Mặc định", null),
BackgroundOption("BG_01", "Nền 1", "backgrounds/background-01.png"),
BackgroundOption("BG_02", "Nền 2", "backgrounds/background-02.png"),
BackgroundOption("BG_03", "Nền 3", "backgrounds/background-03.png")
) + loadDownloadedBackgrounds() + listOf(BackgroundOption("MORE", "Tải thêm", "MORE"))
backgroundOptionAdapter = BackgroundOptionAdapter(backgroundOptions) { option ->
if (option.id == "MORE") {
openOnlineStoreDialog("backgrounds")
} else {
selectedBgAssetPath = option.assetPath
updateBackgroundMode()
}
}
binding.rvBackgroundOptions.adapter = backgroundOptionAdapter
val defaultPresets = listOf(
ColorPreset(
id = "DEFAULT",
name = "Mặc định",
themeCategory = "None",
isEditable = false,
basic = BasicAdjustments(0f, 0f, 0f, 0f, 0f, 0f),
advanced = AdvancedEffects(0f, 0f, 0f),
toneCurve = ToneCurveEmulation(0f, 0f, 0f, 0f),
grain = FilmGrain(0f, 0f)
),
ColorPreset(
id = "instax_faded_warm_01",
name = "Instax Nắng Chiều",
themeCategory = "Retro Vintage",
isEditable = true,
basic = BasicAdjustments(0.05f, -0.15f, -0.20f, 0.25f, 0.18f, -0.02f),
advanced = AdvancedEffects(-0.20f, -0.15f, 0.35f),
toneCurve = ToneCurveEmulation(0.12f, 0.0f, 0.04f, 0.08f),
grain = FilmGrain(0.28f, 0.15f)
),
ColorPreset(
id = "instax_bw_cool",
name = "Instax Trắng Đen",
themeCategory = "Monochrome",
isEditable = true,
basic = BasicAdjustments(0.0f, 0.10f, -1.0f, 0.0f, -0.05f, 0.0f),
advanced = AdvancedEffects(0.05f, 0.0f, 0.20f),
toneCurve = ToneCurveEmulation(0.08f, 0.0f, 0.0f, 0.0f),
grain = FilmGrain(0.35f, 0.18f)
),
ColorPreset(
id = "instax_cool_summer",
name = "Mùa Hè Dịu Mát",
themeCategory = "Summer",
isEditable = true,
basic = BasicAdjustments(0.02f, -0.05f, -0.10f, 0.15f, -0.18f, 0.05f),
advanced = AdvancedEffects(-0.10f, -0.05f, 0.15f),
toneCurve = ToneCurveEmulation(0.05f, 0.02f, 0.05f, 0.10f),
grain = FilmGrain(0.15f, 0.12f)
)
)
colorPresets = defaultPresets + loadCustomPresets() + listOf(
ColorPreset(
id = "MORE",
name = "Tải thêm",
themeCategory = "None",
isEditable = false,
basic = BasicAdjustments(0f, 0f, 0f, 0f, 0f, 0f),
advanced = AdvancedEffects(0f, 0f, 0f),
toneCurve = ToneCurveEmulation(0f, 0f, 0f, 0f),
grain = FilmGrain(0f, 0f)
)
)
presetAdapter.updatePresets(colorPresets)
}
private fun applyDownloadedAsset(item: OnlineStoreItem) {
when (item.category) {
"frames" -> {
val ext = item.url.substringAfterLast(".", "png")
val path = "downloaded_frames/${item.id}.$ext"
setFrameOverlay(path)
frameItemAdapter.selectItem(item.id)
}
"backgrounds" -> {
val ext = item.url.substringAfterLast(".", "png")
val path = "downloaded_backgrounds/${item.id}.$ext"
selectedBgAssetPath = path
updateBackgroundMode()
backgroundOptionAdapter.selectItem(item.id)
}
"presets" -> {
val targetPreset = colorPresets.find { it.id == item.id }
if (targetPreset != null) {
applyPresetFilter(targetPreset)
updatePresetModeIcon(targetPreset)
presetAdapter.selectItem(item.id)
}
}
}
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
@@ -39,6 +39,9 @@ class PresetAdapter(
if (preset.id == "DEFAULT") {
holder.viewPresetColor.setBackgroundResource(R.drawable.ic_block_white)
holder.txtPresetName.text = holder.itemView.context.getString(R.string.default_frame)
} else if (preset.id == "MORE") {
holder.viewPresetColor.setBackgroundResource(R.drawable.ic_more)
holder.txtPresetName.text = "Tải thêm"
} else {
val colorHex = when (preset.id) {
"instax_faded_warm_01" -> "#FFF57C00"
@@ -125,4 +128,14 @@ class PresetAdapter(
notifyItemChanged(previousSelected)
notifyItemChanged(selectedPosition)
}
fun selectItem(presetId: String) {
val index = presets.indexOfFirst { it.id == presetId }
if (index != -1 && index != selectedPosition) {
val oldPos = selectedPosition
selectedPosition = index
notifyItemChanged(oldPos)
notifyItemChanged(selectedPosition)
}
}
}
@@ -0,0 +1,121 @@
package com.photobooth.app
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import java.io.File
data class OnlineStoreItem(
val id: String,
val name: String,
val url: String,
val version: Int,
val category: String, // "frames", "backgrounds", "presets"
var downloadState: DownloadState = DownloadState.NOT_DOWNLOADED
)
enum class DownloadState {
NOT_DOWNLOADED,
DOWNLOADING,
DOWNLOADED
}
class StoreItemAdapter(
private var items: List<OnlineStoreItem>,
private val filesDir: File,
private val onActionClick: (OnlineStoreItem) -> Unit
) : RecyclerView.Adapter<StoreItemAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val imgThumb: ImageView = view.findViewById(R.id.imgStoreAssetThumb)
val txtName: TextView = view.findViewById(R.id.txtStoreAssetName)
val btnAction: Button = view.findViewById(R.id.btnStoreAssetAction)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_online_asset, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.txtName.text = item.name
// Set icon based on category
when (item.category) {
"frames" -> holder.imgThumb.setImageResource(R.drawable.ic_default_frame_thumbnail)
"backgrounds" -> holder.imgThumb.setImageResource(R.drawable.ic_image_gallery)
"presets" -> holder.imgThumb.setImageResource(R.drawable.ic_default_preset_thumbnail)
else -> holder.imgThumb.setImageResource(R.drawable.ic_image_gallery)
}
// Determine if downloaded
val isDownloaded = isAssetDownloaded(item)
if (isDownloaded) {
item.downloadState = DownloadState.DOWNLOADED
} else if (item.downloadState != DownloadState.DOWNLOADING) {
item.downloadState = DownloadState.NOT_DOWNLOADED
}
// Update button appearance based on state
when (item.downloadState) {
DownloadState.NOT_DOWNLOADED -> {
holder.btnAction.text = "Tải về"
holder.btnAction.isEnabled = true
holder.btnAction.setBackgroundColor(
androidx.core.content.ContextCompat.getColor(holder.itemView.context, R.color.theme_primary)
)
}
DownloadState.DOWNLOADING -> {
holder.btnAction.text = "Đang tải..."
holder.btnAction.isEnabled = false
holder.btnAction.setBackgroundColor(
androidx.core.content.ContextCompat.getColor(holder.itemView.context, R.color.theme_item_card_bg)
)
}
DownloadState.DOWNLOADED -> {
holder.btnAction.text = "Áp dụng"
holder.btnAction.isEnabled = true
holder.btnAction.setBackgroundColor(
android.graphics.Color.parseColor("#4CAF50") // Green for Apply
)
}
}
holder.btnAction.setOnClickListener {
onActionClick(item)
}
}
override fun getItemCount(): Int = items.size
fun updateItems(newItems: List<OnlineStoreItem>) {
items = newItems
notifyDataSetChanged()
}
private fun isAssetDownloaded(item: OnlineStoreItem): Boolean {
return when (item.category) {
"frames" -> {
val ext = item.url.substringAfterLast(".", "png")
val localFile = File(filesDir, "downloaded_frames/${item.id}.$ext")
localFile.exists()
}
"backgrounds" -> {
val ext = item.url.substringAfterLast(".", "png")
val localFile = File(filesDir, "downloaded_backgrounds/${item.id}.$ext")
localFile.exists()
}
"presets" -> {
val localFile = File(filesDir, "${item.id}.json")
localFile.exists()
}
else -> false
}
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>
@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/theme_sub_bar_bg"
android:padding="16dp">
<!-- Header Section -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/tvStoreTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:text="Cửa Hàng Tài Nguyên"
android:textColor="@color/theme_white"
android:textSize="18sp"
android:textStyle="bold" />
<ImageButton
android:id="@+id/btnStoreClose"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_close_white"
android:contentDescription="Close Store" />
</RelativeLayout>
<!-- Editable Cloud Server URL Input -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@color/theme_item_card_bg"
android:padding="8dp"
android:layout_marginBottom="16dp"
android:gravity="center_vertical">
<EditText
android:id="@+id/etServerUrl"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="Địa chỉ Cloud Server"
android:textColorHint="#88FFFFFF"
android:textColor="@color/theme_white"
android:textSize="14sp"
android:inputType="textUri"
android:background="@android:color/transparent"
android:text="http://10.0.2.2:8080" />
<Button
android:id="@+id/btnConnectServer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Kết nối"
android:textSize="12sp"
android:textColor="@color/theme_panel_dark"
android:backgroundTint="@color/theme_primary"
android:paddingHorizontal="12dp"
android:minHeight="36dp" />
</LinearLayout>
<!-- Custom Category Tabs -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp"
android:weightSum="3">
<Button
android:id="@+id/btnTabFrames"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="Khung ảnh"
android:textSize="12sp"
android:backgroundTint="@color/theme_primary"
android:textColor="@color/theme_panel_dark"
android:paddingHorizontal="2dp"
android:minHeight="40dp" />
<Button
android:id="@+id/btnTabBackgrounds"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="2dp"
android:text="Phông nền"
android:textSize="12sp"
android:backgroundTint="@color/theme_item_card_bg"
android:textColor="@color/theme_white"
android:paddingHorizontal="2dp"
android:minHeight="40dp" />
<Button
android:id="@+id/btnTabPresets"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Bộ màu"
android:textSize="12sp"
android:backgroundTint="@color/theme_item_card_bg"
android:textColor="@color/theme_white"
android:paddingHorizontal="2dp"
android:minHeight="40dp" />
</LinearLayout>
<!-- List & Loading Container -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="250dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvStoreAssets"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible" />
<ProgressBar
android:id="@+id/progressLoadingStore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminateTint="@color/theme_primary"
android:visibility="gone" />
<!-- Error Layout -->
<LinearLayout
android:id="@+id/layoutStoreError"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Vui lòng kết nối mạng để tải thêm tài nguyên"
android:textColor="#E57373"
android:textSize="14sp"
android:gravity="center"
android:layout_marginBottom="12dp" />
<Button
android:id="@+id/btnStoreRetry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Thử lại"
android:textColor="@color/theme_panel_dark"
android:backgroundTint="@color/theme_primary" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@color/theme_item_card_bg"
android:layout_marginVertical="4dp"
android:padding="8dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="48dp"
android:layout_height="48dp"
app:cardCornerRadius="4dp"
app:strokeWidth="1dp"
app:strokeColor="#44FFFFFF"
android:layout_marginEnd="12dp"
app:cardBackgroundColor="@color/theme_panel_dark">
<ImageView
android:id="@+id/imgStoreAssetThumb"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@drawable/ic_image_gallery" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/txtStoreAssetName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Asset Name"
android:textColor="@color/theme_white"
android:textSize="14sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end" />
<Button
android:id="@+id/btnStoreAssetAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Tải về"
android:textSize="11sp"
android:textColor="@color/theme_panel_dark"
android:backgroundTint="@color/theme_primary"
android:paddingHorizontal="16dp"
android:minHeight="36dp"
android:minWidth="90dp" />
</LinearLayout>