diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0191243f..d3d57b96 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -105,7 +105,10 @@ dependencies { // SymSpell for word suggestions implementation("com.darkrockstudios:symspellkt:3.4.0") - // Glance for Widgets implementation("androidx.glance:glance-appwidget:1.1.0") implementation("androidx.glance:glance-material3:1.1.0") + + // Watermark dependencies + implementation("androidx.exifinterface:exifinterface:1.3.7") + implementation("androidx.compose.material:material-icons-extended:1.7.0") // Compatible with Compose BOM } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2868f341..9c613a0a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -205,6 +205,18 @@ android:taskAffinity="" android:theme="@style/Theme.Essentials.Translucent" /> + + + + + + + + drawOverlay(bitmap, exifData, options) + WatermarkStyle.FRAME -> drawFrame(bitmap, exifData, options) + } + + applyBorder(result, options) + } + + private fun drawOverlay(bitmap: Bitmap, exifData: ExifData, options: WatermarkOptions): Bitmap { + val canvas = Canvas(bitmap) + + // Derive Colors + val colors = deriveColors(options) + val shadowColor = colors.shadowColor + val overlayTextColor = colors.overlayTextColor + + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = overlayTextColor + textSize = bitmap.width * 0.03f // 3% of width + setShadowLayer(4f, 2f, 2f, shadowColor) + } + + val margin = bitmap.width * (options.padding / 1000f) + var yPos = bitmap.height - margin + + // Apply scaling + val brandScale = 0.5f + (options.brandTextSize / 100f) + val dataScale = 0.5f + (options.dataTextSize / 100f) + + // Base text size was 3% of width + val baseSize = bitmap.width * 0.03f + paint.textSize = baseSize * dataScale + + if (options.showExif) { + val exifItems = buildExifList(exifData, options) + if (exifItems.isNotEmpty()) { + val maxWidth = bitmap.width - (margin * 2) + + // Wrap items with icons + val rows = wrapExifItems(exifItems, paint, maxWidth) + val reversedRows = rows.reversed() + + for (row in reversedRows) { + val rowWidth = measureRowWidth(row, paint) + val rowHeight = measureRowHeight(row, paint) + + var xPos = if (options.leftAlignOverlay) margin else (bitmap.width - margin - rowWidth) + + drawExifRow(canvas, row, xPos, yPos, paint, shadowColor) + + yPos -= rowHeight * 1.2f + } + } + } + + + if (options.showCustomText && options.customText.isNotEmpty()) { + val customScale = 0.5f + (options.customTextSize / 100f) + val customPaint = Paint(paint).apply { + textSize = baseSize * customScale + typeface = Typeface.DEFAULT + } + + val textBounds = Rect() + customPaint.getTextBounds(options.customText, 0, options.customText.length, textBounds) + + if (options.showExif) { + yPos -= (customPaint.textSize * 0.5f) + } + + val xPos = if (options.leftAlignOverlay) margin else (bitmap.width - margin - textBounds.width()) + + // Draw Custom Text + canvas.drawText(options.customText, xPos, yPos, customPaint) + + yPos -= customPaint.textSize * 1.2f + } + + if (options.showDeviceBrand) { + val brandString = buildBrandString(exifData) + val brandPaint = Paint(paint).apply { + typeface = Typeface.DEFAULT_BOLD + textSize = baseSize * brandScale // Use brand scale + } + val textBounds = Rect() + brandPaint.getTextBounds(brandString, 0, brandString.length, textBounds) + + val xPos = if (options.leftAlignOverlay) margin else (bitmap.width - margin - textBounds.width()) + + canvas.drawText(brandString, xPos, yPos, brandPaint) + } + + return bitmap + } + + private fun drawFrame(bitmap: Bitmap, exifData: ExifData, options: WatermarkOptions): Bitmap { + var baseFrameHeight = (bitmap.height * 0.10f).roundToInt() + + // Derive Colors + val colors = deriveColors(options) + val bgColor = colors.bgColor + val textColor = colors.textColor + val secondaryTextColor = colors.secondaryTextColor + + // Setup paints early to measure + // Setup paints early to measure + val brandScale = 0.5f + (options.brandTextSize / 100f) + val dataScale = 0.5f + (options.dataTextSize / 100f) + + val brandPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = textColor + textSize = (baseFrameHeight * 0.3f) * brandScale + typeface = Typeface.DEFAULT_BOLD + } + + val exifPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = secondaryTextColor + textSize = (baseFrameHeight * 0.2f) * dataScale + } + + val margin = bitmap.width * (options.padding / 1000f) // 0 to 10% + + val maxAvailableWidth = if (options.showDeviceBrand) { + (bitmap.width - margin * 2) * 0.6f + } else { + (bitmap.width - margin * 2).toFloat() + } + + var exifRows: List> = emptyList() + var totalExifHeight = 0f + + if (options.showExif) { + val exifItems = buildExifList(exifData, options) + if (exifItems.isNotEmpty()) { + exifRows = wrapExifItems(exifItems, exifPaint, maxAvailableWidth) + totalExifHeight = exifRows.size * (exifPaint.textSize * 1.5f) + } + } + + // Dynamic Height Calculation + var leftSideHeight = 0f + if (options.showDeviceBrand) { + leftSideHeight += brandPaint.textSize + } + if (options.showCustomText && options.customText.isNotEmpty()) { + val customTextPaint = Paint(brandPaint).apply { + val customScale = 0.5f + (options.customTextSize / 100f) + textSize = (baseFrameHeight * 0.3f) * customScale + typeface = Typeface.DEFAULT + } + if (options.showDeviceBrand) { + leftSideHeight += (baseFrameHeight * 0.1f) + } + leftSideHeight += customTextPaint.textSize + } + + val contentHeightLeft = leftSideHeight + val contentHeightRight = totalExifHeight + + val minHeight = max(brandPaint.textSize, exifPaint.textSize) * 2f + + val calculatedHeight = max(contentHeightLeft, contentHeightRight) + (margin * 2) + + val finalFrameHeight = max(minHeight.roundToInt(), calculatedHeight.roundToInt()) + + val newHeight = bitmap.height + finalFrameHeight + + val finalBitmap = Bitmap.createBitmap(bitmap.width, newHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(finalBitmap) + + // Draw background + canvas.drawColor(bgColor) + + // Create rounded version of source bitmap if needed + val sourceToDraw = if (options.borderCorner > 0) { + val output = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + val srcCanvas = Canvas(output) + val srcPaint = Paint(Paint.ANTI_ALIAS_FLAG) + val rect = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat()) + + val minDim = kotlin.math.min(bitmap.width, bitmap.height) + val radius = minDim * (options.borderCorner / 1000f) + + srcCanvas.drawRoundRect(rect, radius, radius, srcPaint) + srcPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + srcCanvas.drawBitmap(bitmap, 0f, 0f, srcPaint) + output + } else { + bitmap + } + + // Draw Image and Text + if (options.moveToTop) { + // Draw Image shifted down by frameHeight + canvas.drawBitmap(sourceToDraw, 0f, finalFrameHeight.toFloat(), null) + + // Draw Text in "Forehead" + val centerY = finalFrameHeight / 2f + drawFrameContent( + canvas, exifData, options, margin, centerY, + brandPaint, exifPaint, exifRows, bitmap.width + ) + + } else { + // Draw Image at 0,0 + canvas.drawBitmap(sourceToDraw, 0f, 0f, null) + + // Draw Text in "Chin" + val centerY = bitmap.height + (finalFrameHeight / 2f) + drawFrameContent( + canvas, exifData, options, margin, centerY, + brandPaint, exifPaint, exifRows, bitmap.width + ) + } + + if (sourceToDraw != bitmap) { + sourceToDraw.recycle() + } + + return finalBitmap + } + + private fun drawFrameContent( + canvas: Canvas, exifData: ExifData, options: WatermarkOptions, + margin: Float, centerY: Float, + brandPaint: Paint, exifPaint: Paint, + exifRows: List>, canvasWidth: Int + + ) { + + // Brand & Custom Text on Left + var currentLeftY = centerY + + var totalLeftHeight = 0f + val customScale = 0.5f + (options.customTextSize / 100f) + val customPaint = Paint(brandPaint).apply { + textSize = (brandPaint.textSize / (0.5f + (options.brandTextSize / 100f))) * customScale + + textSize = brandPaint.textSize * (customScale / (0.5f + (options.brandTextSize / 100f))) + typeface = Typeface.DEFAULT + } + + if (options.showDeviceBrand) totalLeftHeight += brandPaint.textSize + if (options.showCustomText && options.customText.isNotEmpty()) { + if (options.showDeviceBrand) totalLeftHeight += (brandPaint.textSize * 0.2f) + totalLeftHeight += customPaint.textSize + } + + currentLeftY = centerY - (totalLeftHeight / 2f) + (brandPaint.textSize / 1.5f) + + currentLeftY = centerY - (totalLeftHeight / 2f) + brandPaint.textSize + + if (options.showDeviceBrand) { + val brandString = buildBrandString(exifData) + canvas.drawText(brandString, margin, currentLeftY, brandPaint) + currentLeftY += (brandPaint.textSize * 0.2f) + customPaint.textSize + } else if (options.showCustomText && options.customText.isNotEmpty()) { + currentLeftY = centerY - (totalLeftHeight / 2f) + customPaint.textSize + } + + if (options.showCustomText && options.customText.isNotEmpty()) { + + canvas.drawText(options.customText, margin, currentLeftY, customPaint) + } + + // Exif on Right + if (options.showExif && exifRows.isNotEmpty()) { + val lineHeight = exifPaint.textSize * 1.5f + + val centeringOffset = (exifRows.size - 1) * lineHeight / 2f + var currentY = (centerY + exifPaint.textSize / 3f) - centeringOffset + + for (row in exifRows) { + val rowWidth = measureRowWidth(row, exifPaint) + val xPos = canvasWidth - margin - rowWidth + drawExifRow(canvas, row, xPos, currentY, exifPaint, null) + currentY += lineHeight + } + } + } + + private fun wrapExifItems(items: List, paint: Paint, maxWidth: Float): List> { + val rows = mutableListOf>() + if (items.isEmpty()) return rows + + var currentRow = mutableListOf() + var currentWidth = 0f + val separatorWidth = 0f + val itemSpacing = paint.textSize * 0.8f + + for (item in items) { + val itemWidth = measureItemWidth(item, paint) + + if (currentRow.isEmpty()) { + currentRow.add(item) + currentWidth += itemWidth + } else { + if (currentWidth + itemSpacing + itemWidth <= maxWidth) { + currentRow.add(item) + currentWidth += itemSpacing + itemWidth + } else { + rows.add(currentRow) + currentRow = mutableListOf(item) + currentWidth = itemWidth + } + } + } + if (currentRow.isNotEmpty()) { + rows.add(currentRow) + } + return rows + } + + private fun measureItemWidth(item: ExifItem, paint: Paint): Float { + // Icon + Padding + Text + val iconSize = paint.textSize * 1.2f + val padding = paint.textSize * 0.4f + val textWidth = paint.measureText(item.text) + return iconSize + padding + textWidth + } + + private fun measureRowWidth(row: List, paint: Paint): Float { + var width = 0f + val itemSpacing = paint.textSize * 0.8f + for (i in row.indices) { + width += measureItemWidth(row[i], paint) + if (i < row.size - 1) width += itemSpacing + } + return width + } + + private fun measureRowHeight(row: List, paint: Paint): Float { + return paint.textSize * 1.5f // Use standard height + } + + private fun drawExifRow( + canvas: Canvas, row: List, + xStart: Float, yPos: Float, + paint: Paint, shadowColor: Int? + ) { + var currentX = xStart + val iconSize = paint.textSize * 1.2f + val padding = paint.textSize * 0.4f + val itemSpacing = paint.textSize * 0.8f + + val iconY = yPos - (paint.textSize / 2f) - (iconSize / 2f) + + for (item in row) { + // Draw Icon + val iconBitmap = loadVectorBitmap(context, item.iconRes, paint.color) + if (iconBitmap != null) { + val destRect = Rect( + currentX.toInt(), + iconY.toInt(), + (currentX + iconSize).toInt(), + (iconY + iconSize).toInt() + ) + + if (shadowColor != null) { + val shadowPaint = Paint(paint).apply { + color = shadowColor + colorFilter = android.graphics.PorterDuffColorFilter(shadowColor, android.graphics.PorterDuff.Mode.SRC_IN) + } + val shadowRect = Rect(destRect) + shadowRect.offset(2, 2) + canvas.drawBitmap(iconBitmap, null, shadowRect, shadowPaint) + } + + canvas.drawBitmap(iconBitmap, null, destRect, null) // Already tinted if we created it tinted + } + + currentX += iconSize + padding + + // Draw Text + canvas.drawText(item.text, currentX, yPos, paint) + + currentX += paint.measureText(item.text) + itemSpacing + } + } + + // Cache for bitmaps + private val iconCache = mutableMapOf() + + private fun loadVectorBitmap(context: Context, resId: Int, color: Int): Bitmap? { + + try { + val drawable = androidx.core.content.ContextCompat.getDrawable(context, resId) ?: return null + val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.setTint(color) + drawable.draw(canvas) + return bitmap + } catch (e: Exception) { + return null + } + } + + private fun buildBrandString(exif: ExifData): String { + return if (!exif.make.isNullOrEmpty() && !exif.model.isNullOrEmpty()) { + if (exif.model.contains(exif.make, ignoreCase = true)) { + exif.model + } else { + "${exif.make} ${exif.model}" + } + } else { + exif.model ?: exif.make ?: "Shot on Device" + } + } + + private data class ExifItem(val text: String, val iconRes: Int) + + private fun buildExifList(exif: ExifData, options: WatermarkOptions): List { + val list = mutableListOf() + + if (options.showFocalLength) exif.focalLength?.let { + list.add(ExifItem(it, R.drawable.rounded_control_camera_24)) + } + if (options.showAperture) exif.aperture?.let { + list.add(ExifItem(it, R.drawable.rounded_camera_24)) + } + if (options.showShutterSpeed) exif.shutterSpeed?.let { + list.add(ExifItem(formatShutterSpeed(it), R.drawable.rounded_shutter_speed_24)) + } + if (options.showIso) exif.iso?.let { + list.add(ExifItem(it, R.drawable.rounded_grain_24)) + } + if (options.showDate) exif.date?.let { + list.add(ExifItem(formatDate(it), R.drawable.rounded_date_range_24)) + } + + return list + } + + private fun formatDate(dateString: String): String { + try { + // Input format: yyyy:MM:dd HH:mm:ss + val inputFormat = java.text.SimpleDateFormat("yyyy:MM:dd HH:mm:ss", java.util.Locale.US) + val date = inputFormat.parse(dateString) ?: return dateString + + // Output format components + val dayFormat = java.text.SimpleDateFormat("d", java.util.Locale.US) + val monthYearFormat = java.text.SimpleDateFormat("MMM yyyy", java.util.Locale.US) + + // Use system time format (12/24h) + val timeFormat = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT) + + val day = dayFormat.format(date).toInt() + val suffix = getDaySuffix(day) + + return "$day$suffix ${monthYearFormat.format(date)}, ${timeFormat.format(date)}" + } catch (e: Exception) { + return dateString + } + } + + private fun getDaySuffix(n: Int): String { + if (n in 11..13) return "th" + return when (n % 10) { + 1 -> "st" + 2 -> "nd" + 3 -> "rd" + else -> "th" + } + } + + private fun formatShutterSpeed(raw: String): String { + // raw usually comes as "0.02s" or "1/100s" from MetadataProvider due to appended "s" in provider + // but if we are robust, we check. + val value = raw.removeSuffix("s") + // If it's a fraction, keep it (photographers prefer fractions) + if (value.contains("/")) return raw + + return try { + val doubleVal = value.toDouble() + // Round to max 2 decimals + // usage of %.2f might result in 0.00 for very fast speeds? + // User asked "maximum of 2 decimals", implying checking if it has more. + // But if it is 0.0005, 0.00 is bad. + // Maybe they mean for long exposures e.g. 2.534s -> 0.53s. + // Let's assume standard formatting. + if (doubleVal >= 1 || doubleVal == 0.0) { + java.lang.String.format(java.util.Locale.US, "%.2fs", doubleVal).removeSuffix(".00s").removeSuffix("0s") + "s" + } else { + // Formatting small decimals + // user request: "round to maximum of 2 decimals" + // If 0.016 -> 0.02s + java.lang.String.format(java.util.Locale.US, "%.2fs", doubleVal) + } + } catch (e: Exception) { + raw + } + } + + private fun applyBorder(bitmap: Bitmap, options: WatermarkOptions): Bitmap { + if (options.borderStroke == 0 && options.borderCorner == 0) return bitmap + + val roundedBitmap = if (options.borderCorner > 0 && options.style != WatermarkStyle.FRAME) { + val output = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val rect = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat()) + + // Mapping: 0-100slider -> 0-10% of min dimension + val minDim = kotlin.math.min(bitmap.width, bitmap.height) + val radius = minDim * (options.borderCorner / 1000f) // Max 10% + + canvas.drawRoundRect(rect, radius, radius, paint) + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + canvas.drawBitmap(bitmap, 0f, 0f, paint) + + if (bitmap != output) bitmap.recycle() + output + } else { + bitmap + } + + // Border Stroke (Expand Canvas) + val finalBitmap = if (options.borderStroke > 0) { + val strokeWidth = (bitmap.width * (options.borderStroke / 1000f)).toInt() + + val newWidth = roundedBitmap.width + (strokeWidth * 2) + val newHeight = roundedBitmap.height + (strokeWidth * 2) + + val output = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + + val colors = deriveColors(options) + val bgColor = colors.bgColor + + canvas.drawColor(bgColor) + + canvas.drawBitmap(roundedBitmap, strokeWidth.toFloat(), strokeWidth.toFloat(), null) + + if (roundedBitmap != output) roundedBitmap.recycle() + output + } else { + roundedBitmap + } + + return finalBitmap + } + + private data class DerivedColors( + val bgColor: Int, + val textColor: Int, + val secondaryTextColor: Int, + val shadowColor: Int, + val overlayTextColor: Int + ) + + private fun deriveColors(options: WatermarkOptions): DerivedColors { + return when (options.colorMode) { + ColorMode.LIGHT -> DerivedColors(Color.WHITE, Color.BLACK, Color.GRAY, Color.BLACK, Color.WHITE) + ColorMode.DARK -> DerivedColors(Color.BLACK, Color.WHITE, Color.LTGRAY, Color.WHITE, Color.BLACK) + ColorMode.ACCENT_LIGHT -> getAccentColors(options.accentColor, false) + ColorMode.ACCENT_DARK -> getAccentColors(options.accentColor, true) + } + } + + private fun getAccentColors(baseColor: Int, dark: Boolean): DerivedColors { + val hsl = FloatArray(3) + androidx.core.graphics.ColorUtils.colorToHSL(baseColor, hsl) + + return if (dark) { + // Accent Dark: Dark BG, Light Text + hsl[2] = 0.15f // Dark BG + val bgColor = androidx.core.graphics.ColorUtils.HSLToColor(hsl) + hsl[2] = 0.9f // Light Text + val textColor = androidx.core.graphics.ColorUtils.HSLToColor(hsl) + hsl[2] = 0.7f // Secondary Text + val secondaryTextColor = androidx.core.graphics.ColorUtils.HSLToColor(hsl) + + DerivedColors(bgColor, textColor, secondaryTextColor, Color.WHITE, bgColor) + } else { + // Accent Light: Light BG, Dark Text + hsl[2] = 0.95f // Light BG + val bgColor = androidx.core.graphics.ColorUtils.HSLToColor(hsl) + hsl[2] = 0.15f // Dark Text + val textColor = androidx.core.graphics.ColorUtils.HSLToColor(hsl) + hsl[2] = 0.4f // Secondary Text + val secondaryTextColor = androidx.core.graphics.ColorUtils.HSLToColor(hsl) + + DerivedColors(bgColor, textColor, secondaryTextColor, Color.BLACK, bgColor) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt new file mode 100644 index 00000000..12a8a622 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt @@ -0,0 +1,150 @@ +package com.sameerasw.essentials.domain.watermark + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +// Setup DataStore extension +private val Context.dataStore by preferencesDataStore(name = "watermark_prefs") + +class WatermarkRepository( + private val context: Context +) { + private val PREF_STYLE = stringPreferencesKey("watermark_style") + private val PREF_SHOW_BRAND = booleanPreferencesKey("show_brand") + private val PREF_SHOW_EXIF = booleanPreferencesKey("show_exif") + private val PREF_SHOW_FOCAL_LENGTH = booleanPreferencesKey("show_focal_length") + private val PREF_SHOW_APERTURE = booleanPreferencesKey("show_aperture") + private val PREF_SHOW_ISO = booleanPreferencesKey("show_iso") + private val PREF_SHOW_SHUTTER = booleanPreferencesKey("show_shutter") + private val PREF_SHOW_DATE = booleanPreferencesKey("show_date") + private val PREF_COLOR_MODE = stringPreferencesKey("color_mode") + private val PREF_ACCENT_COLOR = androidx.datastore.preferences.core.intPreferencesKey("accent_color") + private val PREF_MOVE_TO_TOP = booleanPreferencesKey("move_to_top") + private val PREF_LEFT_ALIGN = booleanPreferencesKey("left_align") + private val PREF_BRAND_TEXT_SIZE = androidx.datastore.preferences.core.intPreferencesKey("brand_text_size") + private val PREF_DATA_TEXT_SIZE = androidx.datastore.preferences.core.intPreferencesKey("data_text_size") + private val PREF_SHOW_CUSTOM_TEXT = booleanPreferencesKey("show_custom_text") + private val PREF_CUSTOM_TEXT = stringPreferencesKey("custom_text") + private val PREF_CUSTOM_TEXT_SIZE = androidx.datastore.preferences.core.intPreferencesKey("custom_text_size") + private val PREF_PADDING = androidx.datastore.preferences.core.intPreferencesKey("padding") + private val PREF_BORDER_STROKE = androidx.datastore.preferences.core.intPreferencesKey("border_stroke") + private val PREF_BORDER_CORNER = androidx.datastore.preferences.core.intPreferencesKey("border_corner") + + val watermarkOptions: Flow = context.dataStore.data + .map { preferences -> + val styleStr = preferences[PREF_STYLE] ?: WatermarkStyle.FRAME.name + val style = try { + WatermarkStyle.valueOf(styleStr) + } catch (e: Exception) { + WatermarkStyle.FRAME + } + + WatermarkOptions( + style = style, + showDeviceBrand = preferences[PREF_SHOW_BRAND] ?: true, + showExif = preferences[PREF_SHOW_EXIF] ?: true, + showFocalLength = preferences[PREF_SHOW_FOCAL_LENGTH] ?: true, + showAperture = preferences[PREF_SHOW_APERTURE] ?: true, + showIso = preferences[PREF_SHOW_ISO] ?: true, + showShutterSpeed = preferences[PREF_SHOW_SHUTTER] ?: true, + showDate = preferences[PREF_SHOW_DATE] ?: false, + colorMode = try { + ColorMode.valueOf(preferences[PREF_COLOR_MODE] ?: ColorMode.LIGHT.name) + } catch (e: Exception) { + ColorMode.LIGHT + }, + accentColor = preferences[PREF_ACCENT_COLOR] ?: android.graphics.Color.GRAY, + moveToTop = preferences[PREF_MOVE_TO_TOP] ?: false, + leftAlignOverlay = preferences[PREF_LEFT_ALIGN] ?: false, + brandTextSize = preferences[PREF_BRAND_TEXT_SIZE] ?: 50, + dataTextSize = preferences[PREF_DATA_TEXT_SIZE] ?: 50, + showCustomText = preferences[PREF_SHOW_CUSTOM_TEXT] ?: false, + customText = preferences[PREF_CUSTOM_TEXT] ?: "", + customTextSize = preferences[PREF_CUSTOM_TEXT_SIZE] ?: 50, + padding = preferences[PREF_PADDING] ?: 50, + borderStroke = preferences[PREF_BORDER_STROKE] ?: 0, + borderCorner = preferences[PREF_BORDER_CORNER] ?: 0 + ) + } + + suspend fun updateStyle(style: WatermarkStyle) { + context.dataStore.edit { it[PREF_STYLE] = style.name } + } + + suspend fun updateShowBrand(show: Boolean) { + context.dataStore.edit { it[PREF_SHOW_BRAND] = show } + } + + suspend fun updateShowExif(show: Boolean) { + context.dataStore.edit { it[PREF_SHOW_EXIF] = show } + } + + suspend fun updateExifSettings( + focalLength: Boolean, + aperture: Boolean, + iso: Boolean, + shutterSpeed: Boolean, + date: Boolean + ) { + context.dataStore.edit { + it[PREF_SHOW_FOCAL_LENGTH] = focalLength + it[PREF_SHOW_APERTURE] = aperture + it[PREF_SHOW_ISO] = iso + it[PREF_SHOW_SHUTTER] = shutterSpeed + it[PREF_SHOW_DATE] = date + } + } + + suspend fun updateColorMode(mode: ColorMode) { + context.dataStore.edit { it[PREF_COLOR_MODE] = mode.name } + } + + suspend fun updateAccentColor(color: Int) { + context.dataStore.edit { it[PREF_ACCENT_COLOR] = color } + } + + suspend fun updateMoveToTop(move: Boolean) { + context.dataStore.edit { it[PREF_MOVE_TO_TOP] = move } + } + + suspend fun updateLeftAlign(left: Boolean) { + context.dataStore.edit { it[PREF_LEFT_ALIGN] = left } + } + + suspend fun updateBrandTextSize(size: Int) { + context.dataStore.edit { it[PREF_BRAND_TEXT_SIZE] = size } + } + + suspend fun updateDataTextSize(size: Int) { + context.dataStore.edit { it[PREF_DATA_TEXT_SIZE] = size } + } + + suspend fun updateCustomTextSettings(show: Boolean, text: String, size: Int) { + context.dataStore.edit { + it[PREF_SHOW_CUSTOM_TEXT] = show + it[PREF_CUSTOM_TEXT] = text + it[PREF_CUSTOM_TEXT_SIZE] = size + } + } + + suspend fun updatePadding(padding: Int) { + context.dataStore.edit { it[PREF_PADDING] = padding } + } + + suspend fun updateCustomTextSize(size: Int) { + context.dataStore.edit { it[PREF_CUSTOM_TEXT_SIZE] = size } + } + + suspend fun updateBorderStroke(stroke: Int) { + context.dataStore.edit { it[PREF_BORDER_STROKE] = stroke } + } + + suspend fun updateBorderCorner(corner: Int) { + context.dataStore.edit { it[PREF_BORDER_CORNER] = corner } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt index 055fc628..405a1b42 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt @@ -53,6 +53,7 @@ fun ReusableTopAppBar( isBeta: Boolean = false, backIconRes: Int = R.drawable.rounded_arrow_back_24, isSmall: Boolean = false, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, actions: @Composable RowScope.() -> Unit = {} ) { val collapsedFraction = scrollBehavior?.state?.collapsedFraction ?: 0f @@ -241,7 +242,7 @@ fun ReusableTopAppBar( if (isSmall) { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer + containerColor = containerColor ), modifier = Modifier.padding(horizontal = 8.dp), title = titleContent, @@ -252,7 +253,7 @@ fun ReusableTopAppBar( } else { LargeFlexibleTopAppBar( colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer + containerColor = containerColor ), modifier = Modifier.padding(horizontal = 8.dp), expandedHeight = if (subtitle != null) 200.dp else 160.dp, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/SegmentedPicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/SegmentedPicker.kt index 7e4c3a4a..8a945f9a 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/SegmentedPicker.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/SegmentedPicker.kt @@ -26,6 +26,7 @@ fun SegmentedPicker( selectedItem: T, onItemSelected: (T) -> Unit, labelProvider: (T) -> String, + iconProvider: (@Composable (T) -> Unit)? = null, modifier: Modifier = Modifier, cornerShape: CornerSize = MaterialTheme.shapes.extraSmall.bottomEnd, ) { @@ -55,7 +56,16 @@ fun SegmentedPicker( else -> ButtonGroupDefaults.connectedMiddleButtonShapes() }, ) { - Text(labelProvider(item)) + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + if (iconProvider != null) { + iconProvider(item) + androidx.compose.foundation.layout.Spacer(Modifier.padding(end = 8.dp)) + } + Text(labelProvider(item)) + } } } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkActivity.kt new file mode 100644 index 00000000..b0d927de --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkActivity.kt @@ -0,0 +1,75 @@ +package com.sameerasw.essentials.ui.composables.watermark + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.WindowCompat +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.theme.EssentialsTheme +import com.sameerasw.essentials.viewmodels.WatermarkViewModel + +class WatermarkActivity : ComponentActivity() { + + private var initialUri by mutableStateOf(null) + + private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + try { + val flag = Intent.FLAG_GRANT_READ_URI_PERMISSION + contentResolver.takePersistableUriPermission(uri, flag) + } catch (e: Exception) { + // Ignore if not persistable + } + initialUri = uri + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + WindowCompat.setDecorFitsSystemWindows(window, false) + super.onCreate(savedInstanceState) + enableEdgeToEdge() + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + + // Handle Share Intent + if (intent?.action == Intent.ACTION_SEND && intent.type?.startsWith("image/") == true) { + (intent.getParcelableExtra(Intent.EXTRA_STREAM))?.let { + initialUri = it + } + } + + setContent { + EssentialsTheme { + Surface(color = MaterialTheme.colorScheme.background) { + val context = LocalContext.current + val viewModel: WatermarkViewModel = viewModel( + factory = WatermarkViewModel.provideFactory(context) + ) + + WatermarkScreen( + initialUri = initialUri, + onPickImage = { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + onBack = { finish() }, + viewModel = viewModel + ) + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkPreview.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkPreview.kt new file mode 100644 index 00000000..c3650a6a --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkPreview.kt @@ -0,0 +1,78 @@ +package com.sameerasw.essentials.ui.composables.watermark + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sameerasw.essentials.R +import com.sameerasw.essentials.viewmodels.WatermarkUiState +import java.io.File + +@Composable +fun WatermarkPreview( + uiState: WatermarkUiState, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when (uiState) { + is WatermarkUiState.Idle -> { + Text( + text = stringResource(R.string.watermark_pick_image), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + is WatermarkUiState.Processing -> { + CircularProgressIndicator() + } + is WatermarkUiState.Success -> { + val targetFile = uiState.file + var visibleFile by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(targetFile) } + var targetReady by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + AsyncImage( + model = visibleFile, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + + if (targetFile != visibleFile) { + AsyncImage( + model = targetFile, + contentDescription = "Preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + onSuccess = { + visibleFile = targetFile + } + ) + } + } + } + is WatermarkUiState.Error -> { + Text( + text = uiState.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkScreen.kt new file mode 100644 index 00000000..e7a7cbae --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkScreen.kt @@ -0,0 +1,912 @@ +package com.sameerasw.essentials.ui.composables.watermark + +import android.graphics.drawable.Icon +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButtonDefaults.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.watermark.ColorMode +import com.sameerasw.essentials.domain.watermark.WatermarkStyle +import com.sameerasw.essentials.ui.components.ReusableTopAppBar +import com.sameerasw.essentials.ui.components.cards.IconToggleItem +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.components.pickers.SegmentedPicker +import com.sameerasw.essentials.ui.components.sliders.ConfigSliderItem +import com.sameerasw.essentials.utils.HapticUtil.performSliderHaptic +import com.sameerasw.essentials.utils.HapticUtil.performUIHaptic +import com.sameerasw.essentials.viewmodels.WatermarkUiState +import com.sameerasw.essentials.viewmodels.WatermarkViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun WatermarkScreen( + initialUri: Uri?, + onPickImage: () -> Unit, + onBack: () -> Unit, + viewModel: WatermarkViewModel +) { + val context = LocalContext.current + val view = androidx.compose.ui.platform.LocalView.current // For haptics + var showExifSheet by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + var showCustomTextSheet by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + + val options by viewModel.options.collectAsState() + val previewState by viewModel.previewUiState.collectAsState() + val saveState by viewModel.uiState.collectAsState() + + LaunchedEffect(initialUri) { + if (initialUri != null) { + viewModel.loadPreview(initialUri) + } + } + + LaunchedEffect(saveState) { + when (saveState) { + is WatermarkUiState.Success -> { + Toast.makeText(context, R.string.watermark_save_success, Toast.LENGTH_SHORT).show() + viewModel.resetState() + } + is WatermarkUiState.Error -> { + Toast.makeText(context, (saveState as WatermarkUiState.Error).message, Toast.LENGTH_SHORT).show() + viewModel.resetState() + } + else -> {} + } + } + + Scaffold( + contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), + topBar = { + ReusableTopAppBar( + title = R.string.feat_watermark_title, + hasBack = true, + onBackClick = { + performUIHaptic(view) + onBack() + }, + isSmall = true, + containerColor = MaterialTheme.colorScheme.background, + actions = { + val pickImageButton = @Composable { + // Pick Image Button + if (initialUri == null) { + // Primary when no image + androidx.compose.material3.Button(onClick = { + performUIHaptic(view) + onPickImage() + }) { + Icon( + painter = androidx.compose.ui.res.painterResource(R.drawable.rounded_add_photo_alternate_24), + contentDescription = stringResource(R.string.watermark_pick_image), + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.watermark_pick_image)) + } + } else { + // Secondary when image is there + androidx.compose.material3.OutlinedButton(onClick = { + performUIHaptic(view) + onPickImage() + }) { + Icon( + painter = androidx.compose.ui.res.painterResource(R.drawable.rounded_add_photo_alternate_24), + contentDescription = stringResource(R.string.watermark_pick_image), + modifier = Modifier.size(18.dp) + ) + } + } + } + + pickImageButton() + + // Save/Share Menu Button + if (initialUri != null) { + Spacer(Modifier.size(8.dp)) + var showMenu by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + + Box { + // Save Button (Primary) + androidx.compose.material3.Button(onClick = { + performUIHaptic(view) + showMenu = true + }) { + Icon( + painter = androidx.compose.ui.res.painterResource(R.drawable.rounded_save_24), + contentDescription = stringResource(R.string.action_save), + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.action_save)) + } + + com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + // Share Option + com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_share)) }, + leadingIcon = { + Icon( + painter = androidx.compose.ui.res.painterResource(R.drawable.rounded_share_24), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }, + onClick = { + showMenu = false + initialUri.let { uri -> + viewModel.shareImage(uri) { sharedUri -> + val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "image/jpeg" + putExtra(android.content.Intent.EXTRA_STREAM, sharedUri) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(android.content.Intent.createChooser(shareIntent, context.getString(R.string.action_share))) + } + } + }, + enabled = saveState !is WatermarkUiState.Processing + ) + + // Save Option + com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_save)) }, + leadingIcon = { + Icon( + painter = androidx.compose.ui.res.painterResource(R.drawable.rounded_save_24), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }, + onClick = { + showMenu = false + viewModel.saveImage(initialUri) + }, + enabled = saveState !is WatermarkUiState.Processing + ) + } + } + } + } + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { padding -> + val density = androidx.compose.ui.platform.LocalDensity.current + val configuration = androidx.compose.ui.platform.LocalConfiguration.current + val screenHeightDp = configuration.screenHeightDp.dp + + val maxPreviewHeightDp = screenHeightDp * 0.6f + val minPreviewHeightDp = screenHeightDp * 0.3f + + val maxPx = with(density) { maxPreviewHeightDp.toPx() } + val minPx = with(density) { minPreviewHeightDp.toPx() } + + var previewHeightPx by androidx.compose.runtime.remember { androidx.compose.runtime.mutableFloatStateOf(maxPx) } + + val nestedScrollConnection = androidx.compose.runtime.remember { + object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection { + override fun onPreScroll(available: androidx.compose.ui.geometry.Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): androidx.compose.ui.geometry.Offset { + val delta = available.y + // Swiping Up (delta < 0): Collapse + if (delta < 0) { + val newHeight = (previewHeightPx + delta).coerceIn(minPx, maxPx) + val consumed = newHeight - previewHeightPx + if (kotlin.math.abs(consumed) > 0.5f) { + performSliderHaptic(view) + } + previewHeightPx = newHeight + return androidx.compose.ui.geometry.Offset(0f, consumed) + } + return androidx.compose.ui.geometry.Offset.Zero + } + + override fun onPostScroll(consumed: androidx.compose.ui.geometry.Offset, available: androidx.compose.ui.geometry.Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): androidx.compose.ui.geometry.Offset { + val delta = available.y + // Swiping Down (delta > 0): Expand + if (delta > 0) { + val newHeight = (previewHeightPx + delta).coerceIn(minPx, maxPx) + val consumedY = newHeight - previewHeightPx + if (kotlin.math.abs(consumedY) > 0.5f) { + performSliderHaptic(view) + } + previewHeightPx = newHeight + return androidx.compose.ui.geometry.Offset(0f, consumedY) + } + return androidx.compose.ui.geometry.Offset.Zero + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .nestedScroll(nestedScrollConnection) + ) { + // Preview Area (Variable Height) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(density) { previewHeightPx.toDp() }) + .padding(16.dp) + .clip(if (initialUri == null) RoundedCornerShape(24.dp) else androidx.compose.ui.graphics.RectangleShape) + .background(if (initialUri == null) MaterialTheme.colorScheme.surfaceContainerHigh else androidx.compose.ui.graphics.Color.Transparent) + .clickable { + performUIHaptic(view) + if (initialUri == null) { + onPickImage() + } + } + .padding(if (initialUri == null) 32.dp else 0.dp), + contentAlignment = Alignment.Center + ) { + if (initialUri == null) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = androidx.compose.ui.res.painterResource(R.drawable.rounded_add_photo_alternate_24), + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.size(8.dp)) + Text( + stringResource(R.string.watermark_pick_image), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + + + // Implementing the "Last Success Persist" logic here locally + val current = previewState + var lastSuccess by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(null) } + + if (current is WatermarkUiState.Success) { + lastSuccess = current + } + + val showBlur = current is WatermarkUiState.Processing + + val blurRadius by androidx.compose.animation.core.animateDpAsState( + targetValue = if (showBlur) 16.dp else 0.dp, + label = "blur" + ) + + val alpha by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (showBlur) 0.6f else 1f, + label = "alpha" + ) + + Box(contentAlignment = Alignment.Center) { + // Underlying Image + if (lastSuccess != null) { + Box( + modifier = Modifier + .blur(blurRadius) + .alpha(alpha) + ) { + WatermarkPreview(uiState = lastSuccess!!) + } + } else { + // First load? + if (current is WatermarkUiState.Processing) { + // First load, show nothing or placeholder maybe + } else { + WatermarkPreview(uiState = current) + } + } + + // Overlay + androidx.compose.animation.AnimatedVisibility( + visible = showBlur, + enter = androidx.compose.animation.fadeIn(), + exit = androidx.compose.animation.fadeOut() + ) { + LoadingIndicator() + } + } + } + } + + // Allow this part to take remaining space and scroll + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(androidx.compose.foundation.rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Controls Area + RoundedCardContainer( + modifier = Modifier.fillMaxWidth() + ) { + // Style Picker + SegmentedPicker( + items = WatermarkStyle.entries, + selectedItem = options.style, + onItemSelected = { + performUIHaptic(view) + viewModel.setStyle(it) + }, + labelProvider = { style -> + when (style) { + WatermarkStyle.OVERLAY -> context.getString(R.string.watermark_style_overlay) + WatermarkStyle.FRAME -> context.getString(R.string.watermark_style_frame) + } + }, + iconProvider = { style -> + val iconRes = when (style) { + WatermarkStyle.OVERLAY -> R.drawable.rounded_magnify_fullscreen_24 + WatermarkStyle.FRAME -> R.drawable.rounded_window_open_24 + } + + Icon( + painter = androidx.compose.ui.res.painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + }, + modifier = Modifier.fillMaxWidth() + ) + + // Style-specific options + if (options.style == WatermarkStyle.FRAME) { + com.sameerasw.essentials.ui.components.cards.IconToggleItem( + iconRes = R.drawable.rounded_top_panel_close_24, + title = stringResource(R.string.watermark_move_to_top), + isChecked = options.moveToTop, + onCheckedChange = { viewModel.setMoveToTop(it) } + ) + } else { + com.sameerasw.essentials.ui.components.cards.IconToggleItem( + iconRes = R.drawable.rounded_position_bottom_left_24, + title = stringResource(R.string.watermark_left_align), + isChecked = options.leftAlignOverlay, + onCheckedChange = { viewModel.setLeftAlign(it) } + ) + } + + // Show Brand Toggle + IconToggleItem( + iconRes = R.drawable.rounded_mobile_text_2_24, + title = stringResource(R.string.watermark_show_brand), + isChecked = options.showDeviceBrand, + onCheckedChange = { viewModel.setShowBrand(it) } + ) + + // Custom Text Entry + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) + ) + .heightIn(min = 56.dp) + .clickable { + performUIHaptic(view) + showCustomTextSheet = true + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Spacer(modifier = Modifier.size(2.dp)) + Icon( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.rounded_edit_note_24), + contentDescription = stringResource(R.string.watermark_custom_text), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.size(2.dp)) + + Text( + text = stringResource(R.string.watermark_custom_text), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + + if (options.showCustomText && options.customText.isNotEmpty()) { + Text( + text = options.customText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 100.dp) + ) + } + + Icon( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.rounded_chevron_right_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Show EXIF Settings (Custom Row with Chevron) + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) + ) + .heightIn(min = 56.dp) // Match standard item height + .clickable { + performUIHaptic(view) + showExifSheet = true + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Spacer(modifier = Modifier.size(2.dp)) + Icon( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.rounded_image_search_24), + contentDescription = stringResource(R.string.watermark_show_exif), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.size(2.dp)) + + Text( + text = stringResource(R.string.watermark_show_exif), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + + Icon( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.rounded_chevron_right_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + + + // Spacing Slider + var paddingValue by androidx.compose.runtime.remember(options.padding) { androidx.compose.runtime.mutableFloatStateOf(options.padding.toFloat()) } + + ConfigSliderItem( + title = stringResource(R.string.watermark_spacing), + value = paddingValue, + onValueChange = { + paddingValue = it + performSliderHaptic(view) + }, + onValueChangeFinished = { viewModel.setPadding(paddingValue.toInt()) }, + valueRange = 0f..100f, + increment = 5f, + valueFormatter = { "${it.toInt()}%" } + ) + } + + + // Font Size Section + val showFontSection = options.showDeviceBrand || options.showExif || options.showCustomText + + if (showFontSection) { + Text( + text = stringResource(R.string.watermark_font_options), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + RoundedCardContainer( + modifier = Modifier.fillMaxWidth() + ) { + if (options.showDeviceBrand) { + var brandSize by androidx.compose.runtime.remember(options.brandTextSize) { androidx.compose.runtime.mutableFloatStateOf(options.brandTextSize.toFloat()) } + + ConfigSliderItem( + title = stringResource(R.string.watermark_text_size_brand), + value = brandSize, + onValueChange = { + brandSize = it + performSliderHaptic(view) + }, + onValueChangeFinished = { viewModel.setBrandTextSize(brandSize.toInt()) }, + valueRange = 0f..100f, + increment = 5f, + valueFormatter = { "${it.toInt()}%" } + ) + } + + if (options.showExif) { + var dataSize by androidx.compose.runtime.remember(options.dataTextSize) { androidx.compose.runtime.mutableFloatStateOf(options.dataTextSize.toFloat()) } + + ConfigSliderItem( + title = stringResource(R.string.watermark_text_size_data), + value = dataSize, + onValueChange = { + dataSize = it + performSliderHaptic(view) + }, + onValueChangeFinished = { viewModel.setDataTextSize(dataSize.toInt()) }, + valueRange = 0f..100f, + increment = 5f, + valueFormatter = { "${it.toInt()}%" } + ) + } + + if (options.showCustomText) { + var customSize by androidx.compose.runtime.remember(options.customTextSize) { androidx.compose.runtime.mutableFloatStateOf(options.customTextSize.toFloat()) } + + ConfigSliderItem( + title = stringResource(R.string.watermark_text_size_custom), + value = customSize, + onValueChange = { + customSize = it + performSliderHaptic(view) + }, + onValueChangeFinished = { viewModel.setCustomTextSize(customSize.toInt()) }, + valueRange = 0f..100f, + increment = 5f, + valueFormatter = { "${it.toInt()}%" } + ) + } + } + } + + + + // Border Section + Text( + text = "Border", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + RoundedCardContainer( + modifier = Modifier.fillMaxWidth() + ) { + var strokeValue by androidx.compose.runtime.remember(options.borderStroke) { androidx.compose.runtime.mutableFloatStateOf(options.borderStroke.toFloat()) } + + ConfigSliderItem( + title = stringResource(R.string.watermark_border_width), + value = strokeValue, + onValueChange = { + strokeValue = it + performSliderHaptic(view) + }, + onValueChangeFinished = { viewModel.setBorderStroke(strokeValue.toInt()) }, + valueRange = 0f..100f, + increment = 5f, + valueFormatter = { "${it.toInt()}%" } + ) + + var cornerValue by androidx.compose.runtime.remember(options.borderCorner) { androidx.compose.runtime.mutableFloatStateOf(options.borderCorner.toFloat()) } + + ConfigSliderItem( + title = stringResource(R.string.watermark_border_corners), + value = cornerValue, + onValueChange = { + cornerValue = it + performSliderHaptic(view) + }, + onValueChangeFinished = { viewModel.setBorderCorner(cornerValue.toInt()) }, + valueRange = 0f..100f, + increment = 5f, + valueFormatter = { "${it.toInt()}%" } + ) + } + + // Color Section + Text( + text = stringResource(R.string.watermark_color_section), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + RoundedCardContainer( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ColorModeOption( + mode = ColorMode.LIGHT, + isSelected = options.colorMode == ColorMode.LIGHT, + onClick = { viewModel.setColorMode(ColorMode.LIGHT) } + ) + ColorModeOption( + mode = ColorMode.DARK, + isSelected = options.colorMode == ColorMode.DARK, + onClick = { viewModel.setColorMode(ColorMode.DARK) } + ) + ColorModeOption( + mode = ColorMode.ACCENT_LIGHT, + accentColor = options.accentColor, + isSelected = options.colorMode == ColorMode.ACCENT_LIGHT, + onClick = { viewModel.setColorMode(ColorMode.ACCENT_LIGHT) } + ) + ColorModeOption( + mode = ColorMode.ACCENT_DARK, + accentColor = options.accentColor, + isSelected = options.colorMode == ColorMode.ACCENT_DARK, + onClick = { viewModel.setColorMode(ColorMode.ACCENT_DARK) } + ) + } + } + + // Bottom spacing for scrolling + Spacer(Modifier.height(24.dp)) + } + } + } + + if (showExifSheet) { + val view = androidx.compose.ui.platform.LocalView.current + androidx.compose.material3.ModalBottomSheet( + onDismissRequest = { showExifSheet = false }, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.watermark_exif_settings), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 8.dp) + ) + + RoundedCardContainer { + // Master Toggle + com.sameerasw.essentials.ui.components.cards.IconToggleItem( + iconRes = R.drawable.rounded_image_search_24, + title = stringResource(R.string.watermark_show_exif), + isChecked = options.showExif, + onCheckedChange = { viewModel.setShowExif(it) } + ) + } + + if (options.showExif) { + RoundedCardContainer { + // Granular toggles + // Helper for granular + val updateExif = { focal: Boolean, aperture: Boolean, iso: Boolean, shutter: Boolean, date: Boolean -> + viewModel.setExifSettings(focal, aperture, iso, shutter, date) + } + + IconToggleItem( + iconRes = R.drawable.rounded_control_camera_24, + title = stringResource(R.string.watermark_exif_focal_length), + isChecked = options.showFocalLength, + onCheckedChange = { updateExif(it, options.showAperture, options.showIso, options.showShutterSpeed, options.showDate) } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_camera_24, + title = stringResource(R.string.watermark_exif_aperture), + isChecked = options.showAperture, + onCheckedChange = { updateExif(options.showFocalLength, it, options.showIso, options.showShutterSpeed, options.showDate) } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_grain_24, + title = stringResource(R.string.watermark_exif_iso), + isChecked = options.showIso, + onCheckedChange = { updateExif(options.showFocalLength, options.showAperture, it, options.showShutterSpeed, options.showDate) } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_shutter_speed_24, + title = stringResource(R.string.watermark_exif_shutter_speed), + isChecked = options.showShutterSpeed, + onCheckedChange = { updateExif(options.showFocalLength, options.showAperture, options.showIso, it, options.showDate) } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_date_range_24, + title = stringResource(R.string.watermark_exif_date), + isChecked = options.showDate, + onCheckedChange = { updateExif(options.showFocalLength, options.showAperture, options.showIso, options.showShutterSpeed, it) } + ) + } + } + } + } + } + + if (showCustomTextSheet) { + val view = androidx.compose.ui.platform.LocalView.current + + // Local state for draft editing + var isEnabled by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(options.showCustomText) } + var draftText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(options.customText) } + + androidx.compose.material3.ModalBottomSheet( + onDismissRequest = { showCustomTextSheet = false }, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.watermark_custom_text_settings), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 8.dp) + ) + + RoundedCardContainer { + // Master Toggle + com.sameerasw.essentials.ui.components.cards.IconToggleItem( + iconRes = R.drawable.rounded_edit_note_24, + title = stringResource(R.string.watermark_custom_text), + isChecked = isEnabled, + onCheckedChange = { isEnabled = it } + ) + } + + if (isEnabled) { + RoundedCardContainer { + Column(modifier = Modifier.padding(16.dp)) { + // Text Input + androidx.compose.material3.OutlinedTextField( + value = draftText, + onValueChange = { draftText = it }, + label = { Text(stringResource(R.string.watermark_custom_text)) }, + placeholder = { Text(stringResource(R.string.watermark_custom_text_hint)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { + performUIHaptic(view) + showCustomTextSheet = false + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.action_cancel)) + } + + Button( + onClick = { + performUIHaptic(view) + viewModel.setCustomTextSettings(isEnabled, draftText, options.customTextSize) // preserve size + showCustomTextSheet = false + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.action_save_changes)) + } + } + } + } + } + } +} + +@Composable +private fun ColorModeOption( + mode: ColorMode, + isSelected: Boolean, + onClick: () -> Unit, + accentColor: Int? = null +) { + val view = androidx.compose.ui.platform.LocalView.current + val color = when (mode) { + ColorMode.LIGHT -> androidx.compose.ui.graphics.Color.White + ColorMode.DARK -> androidx.compose.ui.graphics.Color.Black + ColorMode.ACCENT_LIGHT, ColorMode.ACCENT_DARK -> { + // Derive a preview color for the circle + val base = accentColor ?: android.graphics.Color.GRAY + val hsl = FloatArray(3) + androidx.core.graphics.ColorUtils.colorToHSL(base, hsl) + if (mode == ColorMode.ACCENT_LIGHT) { + hsl[2] = 0.8f // Light shade + } else { + hsl[2] = 0.2f // Dark shade + } + androidx.compose.ui.graphics.Color(androidx.core.graphics.ColorUtils.HSLToColor(hsl)) + } + } + + val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant + val borderWidth = if (isSelected) 3.dp else 1.dp + + Box( + modifier = Modifier + .size(48.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(color) + .border( + width = borderWidth, + color = borderColor, + shape = androidx.compose.foundation.shape.CircleShape + ) + .clickable { + performUIHaptic(view) + onClick() + }, + contentAlignment = Alignment.Center + ) { + if (mode == ColorMode.ACCENT_LIGHT || mode == ColorMode.ACCENT_DARK) { + Icon( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.rounded_image_24), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (mode == ColorMode.ACCENT_LIGHT) androidx.compose.ui.graphics.Color.Black else androidx.compose.ui.graphics.Color.White + ) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt new file mode 100644 index 00000000..fd3a82a1 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt @@ -0,0 +1,385 @@ +package com.sameerasw.essentials.viewmodels + +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.watermark.ColorMode +import com.sameerasw.essentials.domain.watermark.MetadataProvider +import com.sameerasw.essentials.domain.watermark.WatermarkEngine +import com.sameerasw.essentials.domain.watermark.WatermarkOptions +import com.sameerasw.essentials.domain.watermark.WatermarkRepository +import com.sameerasw.essentials.domain.watermark.WatermarkStyle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.File + +sealed class WatermarkUiState { + data object Idle : WatermarkUiState() + data object Processing : WatermarkUiState() + data class Success(val file: File) : WatermarkUiState() + data class Error(val message: String) : WatermarkUiState() +} + +class WatermarkViewModel( + private val watermarkEngine: WatermarkEngine, + private val watermarkRepository: WatermarkRepository, + private val context: Context +) : ViewModel() { + + companion object { + fun provideFactory(context: Context): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val appContext = context.applicationContext + val metadataProvider = MetadataProvider(appContext) + val engine = WatermarkEngine(appContext, metadataProvider) + val repository = WatermarkRepository(appContext) + return WatermarkViewModel(engine, repository, appContext) as T + } + } + } + + private val _uiState = MutableStateFlow(WatermarkUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _previewUiState = MutableStateFlow(WatermarkUiState.Idle) + val previewUiState: StateFlow = _previewUiState.asStateFlow() + + private val _options = MutableStateFlow(WatermarkOptions()) + val options: StateFlow = _options.asStateFlow() + + private var previewSourceBitmap: android.graphics.Bitmap? = null + private var currentUri: Uri? = null + + init { + viewModelScope.launch { + watermarkRepository.watermarkOptions.collectLatest { savedOptions -> + _options.value = savedOptions + updatePreview() + } + } + } + + fun loadPreview(uri: Uri) { + currentUri = uri + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + // Decode scaled version + val inputStream = context.contentResolver.openInputStream(uri) + val options = android.graphics.BitmapFactory.Options() + options.inJustDecodeBounds = true + android.graphics.BitmapFactory.decodeStream(inputStream, null, options) + inputStream?.close() + + // Calculate sample size to fit around 1080p + val reqWidth = 1080 + val reqHeight = 1080 + var inSampleSize = 1 + if (options.outHeight > reqHeight || options.outWidth > reqWidth) { + val halfHeight: Int = options.outHeight / 2 + val halfWidth: Int = options.outWidth / 2 + while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2 + } + } + + val decodeOptions = android.graphics.BitmapFactory.Options().apply { + this.inSampleSize = inSampleSize + this.inMutable = true // Ensure mutable + } + + val is2 = context.contentResolver.openInputStream(uri) + val bitmap = android.graphics.BitmapFactory.decodeStream(is2, null, decodeOptions) + is2?.close() + + if (bitmap != null) { + previewSourceBitmap = bitmap + extractColorFromUri(uri) + updatePreview() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun extractColorFromUri(uri: Uri) { + viewModelScope.launch(Dispatchers.IO) { + try { + val inputStream = context.contentResolver.openInputStream(uri) + val options = android.graphics.BitmapFactory.Options().apply { + inSampleSize = 2 + } + val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream, null, options) + inputStream?.close() + + if (bitmap != null) { + androidx.palette.graphics.Palette.from(bitmap) + .maximumColorCount(32) + .clearFilters() + .generate { palette -> + val color = palette?.vibrantSwatch?.rgb + ?: palette?.mutedSwatch?.rgb + ?: palette?.lightVibrantSwatch?.rgb + ?: palette?.darkVibrantSwatch?.rgb + ?: palette?.dominantSwatch?.rgb + ?: android.graphics.Color.GRAY + + viewModelScope.launch { + watermarkRepository.updateAccentColor(color) + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun updatePreview() { + val bitmap = previewSourceBitmap ?: return + val uri = currentUri ?: return + viewModelScope.launch { + _previewUiState.value = WatermarkUiState.Processing + try { + kotlinx.coroutines.delay(600) + val workingBitmap = bitmap.copy(bitmap.config ?: android.graphics.Bitmap.Config.ARGB_8888, true) + + val result = watermarkEngine.processBitmap(workingBitmap, uri, _options.value) + + val timestamp = System.currentTimeMillis() + val file = File(context.cacheDir, "preview_watermark_$timestamp.jpg") + val out = java.io.FileOutputStream(file) + result.compress(android.graphics.Bitmap.CompressFormat.JPEG, 80, out) + out.close() + + (_previewUiState.value as? WatermarkUiState.Success)?.file?.let { oldFile -> + if (oldFile.exists() && oldFile.name.startsWith("preview_watermark_")) { + oldFile.delete() + } + } + + _previewUiState.value = WatermarkUiState.Success(file) + } catch (e: Exception) { + e.printStackTrace() + _previewUiState.value = WatermarkUiState.Error(e.message ?: "Unknown error") + } + } + } + + fun setStyle(style: WatermarkStyle) { + viewModelScope.launch { + watermarkRepository.updateStyle(style) + } + } + + fun setShowBrand(show: Boolean) { + viewModelScope.launch { + watermarkRepository.updateShowBrand(show) + } + } + + fun setShowExif(show: Boolean) { + viewModelScope.launch { + watermarkRepository.updateShowExif(show) + } + } + + fun setExifSettings( + focalLength: Boolean, + aperture: Boolean, + iso: Boolean, + shutterSpeed: Boolean, + date: Boolean + ) { + viewModelScope.launch { + watermarkRepository.updateExifSettings(focalLength, aperture, iso, shutterSpeed, date) + // Trigger preview update + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setColorMode(mode: ColorMode) { + viewModelScope.launch { + watermarkRepository.updateColorMode(mode) + } + } + + fun setMoveToTop(move: Boolean) { + viewModelScope.launch { + watermarkRepository.updateMoveToTop(move) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setLeftAlign(left: Boolean) { + viewModelScope.launch { + watermarkRepository.updateLeftAlign(left) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setBrandTextSize(size: Int) { + viewModelScope.launch { + watermarkRepository.updateBrandTextSize(size) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setDataTextSize(size: Int) { + viewModelScope.launch { + watermarkRepository.updateDataTextSize(size) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setCustomTextSettings(show: Boolean, text: String, size: Int) { + viewModelScope.launch { + watermarkRepository.updateCustomTextSettings(show, text, size) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setCustomTextSize(size: Int) { + viewModelScope.launch { + watermarkRepository.updateCustomTextSize(size) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setPadding(padding: Int) { + viewModelScope.launch { + watermarkRepository.updatePadding(padding) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setBorderStroke(stroke: Int) { + viewModelScope.launch { + watermarkRepository.updateBorderStroke(stroke) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setBorderCorner(corner: Int) { + viewModelScope.launch { + watermarkRepository.updateBorderCorner(corner) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun saveImage(uri: Uri) { + viewModelScope.launch { + _uiState.value = WatermarkUiState.Processing + try { + // Process image to a temporary file first + val tempFile = watermarkEngine.processImage(uri, _options.value) + + // Save to MediaStore (Gallery) + val values = android.content.ContentValues().apply { + put( + android.provider.MediaStore.Images.Media.DISPLAY_NAME, + "WM_${System.currentTimeMillis()}.jpg" + ) + put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + // RELATIVE_PATH is available on Android 10+ (API 29) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + put( + android.provider.MediaStore.Images.Media.RELATIVE_PATH, + "Pictures/Essentials" + ) + } + } + + val resolver = context.contentResolver + val collection = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + android.provider.MediaStore.Images.Media.getContentUri(android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else { + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + + val resultUri = resolver.insert(collection, values) + + if (resultUri != null) { + resolver.openOutputStream(resultUri)?.use { outStream -> + tempFile.inputStream().use { inStream -> + inStream.copyTo(outStream) + } + } + _uiState.value = + WatermarkUiState.Success(tempFile) // Or success with URI? State expects File, but it's just for success message. + } else { + throw Exception("Failed to create MediaStore entry") + } + } catch (e: Exception) { + e.printStackTrace() + _uiState.value = WatermarkUiState.Error(e.message ?: "Unknown error") + } + } + } + + fun shareImage(uri: Uri, onShareReady: (Uri) -> Unit) { + viewModelScope.launch { + _uiState.value = WatermarkUiState.Processing + try { + // Process image to a temporary file + val tempFile = watermarkEngine.processImage(uri, _options.value) + val savedUri = saveToMediaStore(tempFile) + if (savedUri != null) { + _uiState.value = WatermarkUiState.Idle + onShareReady(savedUri) + } else { + _uiState.value = WatermarkUiState.Error("Failed to prepare image for sharing") + } + } catch (e: Exception) { + e.printStackTrace() + _uiState.value = WatermarkUiState.Error(e.message ?: "Unknown error") + } + } + } + + private fun saveToMediaStore(sourceFile: File): Uri? { + try { + val values = android.content.ContentValues().apply { + put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, "WM_SHARE_${System.currentTimeMillis()}.jpg") + 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/Essentials/Watermarks") + } + } + val resolver = context.contentResolver + val collection = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + android.provider.MediaStore.Images.Media.getContentUri(android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else { + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + + val resultUri = resolver.insert(collection, values) ?: return null + + resolver.openOutputStream(resultUri)?.use { outStream -> + sourceFile.inputStream().use { inStream -> + inStream.copyTo(outStream) + } + } + return resultUri + } catch (e: Exception) { + e.printStackTrace() + return null + } + } + + fun resetState() { + _uiState.value = WatermarkUiState.Idle + } +} diff --git a/app/src/main/res/drawable/rounded_add_photo_alternate_24.xml b/app/src/main/res/drawable/rounded_add_photo_alternate_24.xml new file mode 100644 index 00000000..0b44125a --- /dev/null +++ b/app/src/main/res/drawable/rounded_add_photo_alternate_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_camera_24.xml b/app/src/main/res/drawable/rounded_camera_24.xml new file mode 100644 index 00000000..3a01dc0c --- /dev/null +++ b/app/src/main/res/drawable/rounded_camera_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_control_camera_24.xml b/app/src/main/res/drawable/rounded_control_camera_24.xml new file mode 100644 index 00000000..a02edfd2 --- /dev/null +++ b/app/src/main/res/drawable/rounded_control_camera_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_date_range_24.xml b/app/src/main/res/drawable/rounded_date_range_24.xml new file mode 100644 index 00000000..52f34f06 --- /dev/null +++ b/app/src/main/res/drawable/rounded_date_range_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_edit_note_24.xml b/app/src/main/res/drawable/rounded_edit_note_24.xml new file mode 100644 index 00000000..0870396a --- /dev/null +++ b/app/src/main/res/drawable/rounded_edit_note_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_grain_24.xml b/app/src/main/res/drawable/rounded_grain_24.xml new file mode 100644 index 00000000..1cb3e4b3 --- /dev/null +++ b/app/src/main/res/drawable/rounded_grain_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_image_24.xml b/app/src/main/res/drawable/rounded_image_24.xml new file mode 100644 index 00000000..5544c381 --- /dev/null +++ b/app/src/main/res/drawable/rounded_image_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_image_search_24.xml b/app/src/main/res/drawable/rounded_image_search_24.xml new file mode 100644 index 00000000..1fc7cba9 --- /dev/null +++ b/app/src/main/res/drawable/rounded_image_search_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_position_bottom_left_24.xml b/app/src/main/res/drawable/rounded_position_bottom_left_24.xml new file mode 100644 index 00000000..a78a00cc --- /dev/null +++ b/app/src/main/res/drawable/rounded_position_bottom_left_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_shutter_speed_24.xml b/app/src/main/res/drawable/rounded_shutter_speed_24.xml new file mode 100644 index 00000000..6ab96847 --- /dev/null +++ b/app/src/main/res/drawable/rounded_shutter_speed_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_top_panel_close_24.xml b/app/src/main/res/drawable/rounded_top_panel_close_24.xml new file mode 100644 index 00000000..3116aa89 --- /dev/null +++ b/app/src/main/res/drawable/rounded_top_panel_close_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_window_open_24.xml b/app/src/main/res/drawable/rounded_window_open_24.xml new file mode 100644 index 00000000..08c6cc65 --- /dev/null +++ b/app/src/main/res/drawable/rounded_window_open_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d811709c..7a423e50 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -324,6 +324,42 @@ Secure apps with biometrics Freeze Disable rarely used apps + Watermark + Add EXIF data and logos to photos + Style + Overlay + Frame + Device Brand + EXIF Data + Pick Image + Image saved to gallery + Failed to save image + Processing... + Share + EXIF Settings + Focal Length + Aperture + ISO + Shutter Speed + Date & Time + Move to Top + Align Left + Brand Size + Data Size + Text Size + Font Size + Custom Text + Enter your text... + Custom Text Settings + Spacing + Border Width + Round Corners + Color + Light + Dark + Accent Light + Accent Dark + Save Changes Widget Haptic feedback