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