From 31352a46b1545e2ba3c218a330e26c517e0719a8 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 23 Jan 2026 15:13:22 +0530 Subject: [PATCH 1/7] initial watermark implementation --- app/build.gradle.kts | 5 +- app/src/main/AndroidManifest.xml | 12 + .../domain/registry/FeatureRegistry.kt | 15 + .../domain/watermark/MetadataProvider.kt | 45 ++ .../domain/watermark/WatermarkEngine.kt | 232 +++++++++++ .../domain/watermark/WatermarkRepository.kt | 54 +++ .../ui/components/pickers/SegmentedPicker.kt | 12 +- .../watermark/WatermarkActivity.kt | 70 ++++ .../composables/watermark/WatermarkPreview.kt | 78 ++++ .../composables/watermark/WatermarkScreen.kt | 393 ++++++++++++++++++ .../viewmodels/WatermarkViewModel.kt | 272 ++++++++++++ .../rounded_add_photo_alternate_24.xml | 5 + .../res/drawable/rounded_window_open_24.xml | 5 + app/src/main/res/values/strings.xml | 12 + 14 files changed, 1208 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/watermark/MetadataProvider.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkActivity.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkPreview.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkScreen.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt create mode 100644 app/src/main/res/drawable/rounded_add_photo_alternate_24.xml create mode 100644 app/src/main/res/drawable/rounded_window_open_24.xml 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) + } + } + + private fun drawOverlay(bitmap: Bitmap, exifData: ExifData, options: WatermarkOptions): Bitmap { + val canvas = Canvas(bitmap) + val useDark = options.useDarkTheme + val textColor = if (useDark) Color.BLACK else Color.WHITE + val shadowColor = if (useDark) Color.WHITE else Color.BLACK + + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = textColor + textSize = bitmap.width * 0.03f // 3% of width + setShadowLayer(4f, 2f, 2f, shadowColor) + } + + val margin = bitmap.width * 0.05f + var yPos = bitmap.height - margin + + if (options.showExif) { + val exifString = buildExifString(exifData) + if (exifString.isNotEmpty()) { + val textBounds = Rect() + paint.getTextBounds(exifString, 0, exifString.length, textBounds) + canvas.drawText(exifString, bitmap.width - margin - textBounds.width(), yPos, paint) + yPos -= textBounds.height() * 1.5f + } + } + + if (options.showDeviceBrand) { + val brandString = buildBrandString(exifData) + val brandPaint = Paint(paint).apply { + typeface = Typeface.DEFAULT_BOLD + } + val textBounds = Rect() + brandPaint.getTextBounds(brandString, 0, brandString.length, textBounds) + canvas.drawText(brandString, bitmap.width - margin - textBounds.width(), yPos, brandPaint) + } + + return bitmap + } + + private fun drawFrame(bitmap: Bitmap, exifData: ExifData, options: WatermarkOptions): Bitmap { + val frameHeight = (bitmap.height * 0.10f).roundToInt() // 10% chin + val newHeight = bitmap.height + frameHeight + + val finalBitmap = Bitmap.createBitmap(bitmap.width, newHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(finalBitmap) + + val useDark = options.useDarkTheme + val bgColor = if (useDark) Color.BLACK else Color.WHITE + val textColor = if (useDark) Color.WHITE else Color.BLACK + val secondaryTextColor = if (useDark) Color.LTGRAY else Color.GRAY + + // Draw background + canvas.drawColor(bgColor) + + // Draw original image + canvas.drawBitmap(bitmap, 0f, 0f, null) + + // Draw Text in Chin + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = textColor + textSize = frameHeight * 0.25f + } + + val leftMargin = bitmap.width * 0.05f + val centerY = bitmap.height + (frameHeight / 2f) + (paint.textSize / 3f) + + // Left side: Brand / Model + if (options.showDeviceBrand) { + val brandPaint = Paint(paint).apply { + typeface = Typeface.DEFAULT_BOLD + textSize = frameHeight * 0.3f + } + val brandString = buildBrandString(exifData) + canvas.drawText(brandString, leftMargin, centerY, brandPaint) + } + + // Right side: EXIF + if (options.showExif) { + val exifString = buildExifString(exifData) + val exifPaint = Paint(paint).apply { + color = secondaryTextColor + textSize = frameHeight * 0.2f + } + val textBounds = Rect() + exifPaint.getTextBounds(exifString, 0, exifString.length, textBounds) + canvas.drawText(exifString, bitmap.width - leftMargin - textBounds.width(), centerY, exifPaint) + } + + return finalBitmap + } + + 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 fun buildExifString(exif: ExifData): String { + val parts = mutableListOf() + exif.focalLength?.let { parts.add(it) } + exif.aperture?.let { parts.add(it) } + exif.shutterSpeed?.let { parts.add(it) } + exif.iso?.let { parts.add(it) } + return parts.joinToString(" • ") + } +} 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..f50f2960 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt @@ -0,0 +1,54 @@ +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_USE_DARK_THEME = booleanPreferencesKey("use_dark_theme") + + 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, + useDarkTheme = preferences[PREF_USE_DARK_THEME] ?: false + ) + } + + 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 updateUseDarkTheme(useDark: Boolean) { + context.dataStore.edit { it[PREF_USE_DARK_THEME] = useDark } + } +} 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..d1487121 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkActivity.kt @@ -0,0 +1,70 @@ +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.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?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // 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..a8e28a76 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkScreen.kt @@ -0,0 +1,393 @@ +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.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.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.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.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 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( + topBar = { + ReusableTopAppBar( + title = R.string.feat_watermark_title, + hasBack = true, + onBackClick = onBack, + isSmall = true, + actions = { + val pickImageButton = @Composable { + // Pick Image Button + if (initialUri == null) { + // Primary when no image + androidx.compose.material3.Button(onClick = 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 = 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 = { 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.surfaceContainer + ) { 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.4f + + 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 + 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 + 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(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable { + if (initialUri == null) { + onPickImage() + } else { + viewModel.toggleContrast() + } + } + .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 = { 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() + ) + + // Show Brand Toggle + IconToggleItem( + iconRes = R.drawable.rounded_info_24, + title = stringResource(R.string.watermark_show_brand), + isChecked = options.showDeviceBrand, + onCheckedChange = { viewModel.setShowBrand(it) } + ) + + // Show EXIF Toggle + IconToggleItem( + iconRes = R.drawable.rounded_info_24, + title = stringResource(R.string.watermark_show_exif), + isChecked = options.showExif, + onCheckedChange = { viewModel.setShowExif(it) } + ) + } + + // Bottom spacing for scrolling + Spacer(Modifier.height(24.dp)) + } + } + } + } +} 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..6561df51 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt @@ -0,0 +1,272 @@ +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.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.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 + updatePreview() + } + } 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 toggleContrast() { + viewModelScope.launch { + watermarkRepository.updateUseDarkTheme(!_options.value.useDarkTheme) + } + } + + 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_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..8859a7f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -324,6 +324,18 @@ Secure apps with biometrics Freeze Disable rarely used apps + Watermarks + Add EXIF data and logos to photos + Style + Overlay + Frame + Show Device Brand + Show EXIF Data + Pick Image + Image saved to gallery + Failed to save image + Processing... + Share Widget Haptic feedback From d694b6e57dce1169f6a969184a0cf8c05ce44b01 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 23 Jan 2026 16:50:32 +0530 Subject: [PATCH 2/7] A lot of watermark options and controls added --- .../domain/watermark/WatermarkEngine.kt | 381 +++++++++++++++--- .../domain/watermark/WatermarkRepository.kt | 52 ++- .../ui/components/ReusableTopAppBar.kt | 5 +- .../composables/watermark/WatermarkScreen.kt | 214 +++++++++- .../viewmodels/WatermarkViewModel.kt | 42 ++ .../main/res/drawable/rounded_camera_24.xml | 5 + .../drawable/rounded_control_camera_24.xml | 5 + .../res/drawable/rounded_date_range_24.xml | 5 + .../main/res/drawable/rounded_grain_24.xml | 5 + .../res/drawable/rounded_image_search_24.xml | 5 + .../rounded_position_bottom_left_24.xml | 5 + .../res/drawable/rounded_shutter_speed_24.xml | 5 + .../drawable/rounded_top_panel_close_24.xml | 5 + app/src/main/res/values/strings.xml | 14 +- 14 files changed, 686 insertions(+), 62 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_camera_24.xml create mode 100644 app/src/main/res/drawable/rounded_control_camera_24.xml create mode 100644 app/src/main/res/drawable/rounded_date_range_24.xml create mode 100644 app/src/main/res/drawable/rounded_grain_24.xml create mode 100644 app/src/main/res/drawable/rounded_image_search_24.xml create mode 100644 app/src/main/res/drawable/rounded_position_bottom_left_24.xml create mode 100644 app/src/main/res/drawable/rounded_shutter_speed_24.xml create mode 100644 app/src/main/res/drawable/rounded_top_panel_close_24.xml diff --git a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt index b211bc70..400071a1 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt @@ -26,9 +26,19 @@ data class WatermarkOptions( val style: WatermarkStyle = WatermarkStyle.FRAME, val showDeviceBrand: Boolean = true, val showExif: Boolean = true, + // Granular EXIF options + val showFocalLength: Boolean = true, + val showAperture: Boolean = true, + val showIso: Boolean = true, + val showShutterSpeed: Boolean = true, + val showDate: Boolean = false, val customText: String = "", val outputQuality: Int = 100, - val useDarkTheme: Boolean = false + val useDarkTheme: Boolean = false, + val moveToTop: Boolean = false, + val leftAlignOverlay: Boolean = false, + val brandTextSize: Int = 50, + val dataTextSize: Int = 50 ) class WatermarkEngine( @@ -134,13 +144,33 @@ class WatermarkEngine( val margin = bitmap.width * 0.05f 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 exifString = buildExifString(exifData) - if (exifString.isNotEmpty()) { - val textBounds = Rect() - paint.getTextBounds(exifString, 0, exifString.length, textBounds) - canvas.drawText(exifString, bitmap.width - margin - textBounds.width(), yPos, paint) - yPos -= textBounds.height() * 1.5f + 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.5f + } } } @@ -148,65 +178,251 @@ class WatermarkEngine( 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) - canvas.drawText(brandString, bitmap.width - margin - textBounds.width(), yPos, brandPaint) + + 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 { - val frameHeight = (bitmap.height * 0.10f).roundToInt() // 10% chin - val newHeight = bitmap.height + frameHeight - - val finalBitmap = Bitmap.createBitmap(bitmap.width, newHeight, Bitmap.Config.ARGB_8888) - val canvas = Canvas(finalBitmap) + var baseFrameHeight = (bitmap.height * 0.10f).roundToInt() val useDark = options.useDarkTheme val bgColor = if (useDark) Color.BLACK else Color.WHITE val textColor = if (useDark) Color.WHITE else Color.BLACK val secondaryTextColor = if (useDark) Color.LTGRAY else Color.GRAY + + // 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 * 0.05f + + 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 + val requiredHeight = max( + brandPaint.textSize * 2.5f, + totalExifHeight + (exifPaint.textSize * 2f) + ).roundToInt() + + val finalFrameHeight = max(baseFrameHeight, requiredHeight) + 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) - // Draw original image - canvas.drawBitmap(bitmap, 0f, 0f, null) + // Draw Image and Text + if (options.moveToTop) { + // Draw Image shifted down by frameHeight + canvas.drawBitmap(bitmap, 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(bitmap, 0f, 0f, null) + + // Draw Text in "Chin" + val centerY = bitmap.height + (finalFrameHeight / 2f) + drawFrameContent( + canvas, exifData, options, margin, centerY, + brandPaint, exifPaint, exifRows, bitmap.width + ) + } + + return finalBitmap + } + + private fun drawFrameContent( + canvas: Canvas, exifData: ExifData, options: WatermarkOptions, + margin: Float, centerY: Float, + brandPaint: Paint, exifPaint: Paint, + exifRows: List>, canvasWidth: Int + + ) { - // Draw Text in Chin - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = textColor - textSize = frameHeight * 0.25f + // Brand on Left + if (options.showDeviceBrand) { + val brandString = buildBrandString(exifData) + // Vertical center: centerY + half text height (as baseline) + val brandY = centerY + (brandPaint.textSize / 3f) + canvas.drawText(brandString, margin, brandY, brandPaint) } - val leftMargin = bitmap.width * 0.05f - val centerY = bitmap.height + (frameHeight / 2f) + (paint.textSize / 3f) + // 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 - // Left side: Brand / Model - if (options.showDeviceBrand) { - val brandPaint = Paint(paint).apply { - typeface = Typeface.DEFAULT_BOLD - textSize = frameHeight * 0.3f + 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 + } } - val brandString = buildBrandString(exifData) - canvas.drawText(brandString, leftMargin, centerY, brandPaint) } + 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 + } - // Right side: EXIF - if (options.showExif) { - val exifString = buildExifString(exifData) - val exifPaint = Paint(paint).apply { - color = secondaryTextColor - textSize = frameHeight * 0.2f + 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 } - val textBounds = Rect() - exifPaint.getTextBounds(exifString, 0, exifString.length, textBounds) - canvas.drawText(exifString, bitmap.width - leftMargin - textBounds.width(), centerY, exifPaint) + + currentX += iconSize + padding + + // Draw Text + canvas.drawText(item.text, currentX, yPos, paint) + + currentX += paint.measureText(item.text) + itemSpacing } + } - return finalBitmap + // 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 { @@ -221,12 +437,87 @@ class WatermarkEngine( } } - private fun buildExifString(exif: ExifData): String { - val parts = mutableListOf() - exif.focalLength?.let { parts.add(it) } - exif.aperture?.let { parts.add(it) } - exif.shutterSpeed?.let { parts.add(it) } - exif.iso?.let { parts.add(it) } - return parts.joinToString(" • ") + 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 + } } } 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 index f50f2960..6293e993 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt @@ -17,7 +17,16 @@ class WatermarkRepository( 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_USE_DARK_THEME = booleanPreferencesKey("use_dark_theme") + 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") val watermarkOptions: Flow = context.dataStore.data .map { preferences -> @@ -32,7 +41,16 @@ class WatermarkRepository( style = style, showDeviceBrand = preferences[PREF_SHOW_BRAND] ?: true, showExif = preferences[PREF_SHOW_EXIF] ?: true, - useDarkTheme = preferences[PREF_USE_DARK_THEME] ?: false + 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, + useDarkTheme = preferences[PREF_USE_DARK_THEME] ?: false, + 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 ) } @@ -47,8 +65,40 @@ class WatermarkRepository( 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 updateUseDarkTheme(useDark: Boolean) { context.dataStore.edit { it[PREF_USE_DARK_THEME] = useDark } } + + 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 } + } } 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/composables/watermark/WatermarkScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkScreen.kt index a8e28a76..40d8fbef 100644 --- 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 @@ -49,6 +49,9 @@ 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 @@ -61,6 +64,9 @@ fun WatermarkScreen( 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) } + val options by viewModel.options.collectAsState() val previewState by viewModel.previewUiState.collectAsState() val saveState by viewModel.uiState.collectAsState() @@ -90,14 +96,21 @@ fun WatermarkScreen( ReusableTopAppBar( title = R.string.feat_watermark_title, hasBack = true, - onBackClick = onBack, + 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 = onPickImage) { + 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), @@ -108,7 +121,10 @@ fun WatermarkScreen( } } else { // Secondary when image is there - androidx.compose.material3.OutlinedButton(onClick = onPickImage) { + 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), @@ -127,7 +143,10 @@ fun WatermarkScreen( Box { // Save Button (Primary) - androidx.compose.material3.Button(onClick = { showMenu = true }) { + 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), @@ -189,7 +208,7 @@ fun WatermarkScreen( } ) }, - containerColor = MaterialTheme.colorScheme.surfaceContainer + containerColor = MaterialTheme.colorScheme.background ) { padding -> val density = androidx.compose.ui.platform.LocalDensity.current val configuration = androidx.compose.ui.platform.LocalConfiguration.current @@ -211,6 +230,9 @@ fun WatermarkScreen( 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) } @@ -223,6 +245,9 @@ fun WatermarkScreen( 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) } @@ -246,6 +271,7 @@ fun WatermarkScreen( .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .clickable { + performUIHaptic(view) if (initialUri == null) { onPickImage() } else { @@ -345,7 +371,10 @@ fun WatermarkScreen( SegmentedPicker( items = WatermarkStyle.entries, selectedItem = options.style, - onItemSelected = { viewModel.setStyle(it) }, + onItemSelected = { + performUIHaptic(view) + viewModel.setStyle(it) + }, labelProvider = { style -> when (style) { WatermarkStyle.OVERLAY -> context.getString(R.string.watermark_style_overlay) @@ -366,26 +395,187 @@ fun WatermarkScreen( }, 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_info_24, + iconRes = R.drawable.rounded_mobile_text_2_24, title = stringResource(R.string.watermark_show_brand), isChecked = options.showDeviceBrand, onCheckedChange = { viewModel.setShowBrand(it) } ) - // Show EXIF Toggle - IconToggleItem( - iconRes = R.drawable.rounded_info_24, + // 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 { + com.sameerasw.essentials.utils.HapticUtil.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 + ) + } + + // Text Size Sliders + 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 + com.sameerasw.essentials.utils.HapticUtil.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 + com.sameerasw.essentials.utils.HapticUtil.performSliderHaptic(view) + }, + onValueChangeFinished = { viewModel.setDataTextSize(dataSize.toInt()) }, + valueRange = 0f..100f, + increment = 5f, + valueFormatter = { "${it.toInt()}%" } + ) + } + } + + // 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) } ) } - // Bottom spacing for scrolling - Spacer(Modifier.height(24.dp)) + 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) } + ) + } + } } } } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt index 6561df51..2025ba04 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt @@ -159,12 +159,54 @@ class WatermarkViewModel( } } + 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 toggleContrast() { viewModelScope.launch { watermarkRepository.updateUseDarkTheme(!_options.value.useDarkTheme) } } + 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 saveImage(uri: Uri) { viewModelScope.launch { _uiState.value = WatermarkUiState.Processing 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_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_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/values/strings.xml b/app/src/main/res/values/strings.xml index 8859a7f1..52a4dae9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -329,13 +329,23 @@ Style Overlay Frame - Show Device Brand - Show EXIF Data + 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 Widget Haptic feedback From 54958cb29d4b267599154efb522ab88a274e5ef7 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 23 Jan 2026 16:54:44 +0530 Subject: [PATCH 3/7] Fix edge to edge in watermark --- .../essentials/ui/composables/watermark/WatermarkActivity.kt | 5 +++++ .../essentials/ui/composables/watermark/WatermarkScreen.kt | 1 + 2 files changed, 6 insertions(+) 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 index d1487121..b0d927de 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -37,8 +38,12 @@ class WatermarkActivity : ComponentActivity() { } 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) { 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 index 40d8fbef..fedbce4f 100644 --- 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 @@ -92,6 +92,7 @@ fun WatermarkScreen( } Scaffold( + contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), topBar = { ReusableTopAppBar( title = R.string.feat_watermark_title, From def381bc4f50cbd8906c446da0fd4d56b57ddd19 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 23 Jan 2026 17:19:16 +0530 Subject: [PATCH 4/7] Custom text in watermark --- .../domain/watermark/WatermarkEngine.kt | 104 +++++++++-- .../domain/watermark/WatermarkRepository.kt | 22 ++- .../composables/watermark/WatermarkScreen.kt | 174 ++++++++++++++++++ .../viewmodels/WatermarkViewModel.kt | 14 ++ .../res/drawable/rounded_edit_note_24.xml | 5 + app/src/main/res/values/strings.xml | 6 + 6 files changed, 308 insertions(+), 17 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_edit_note_24.xml diff --git a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt index 400071a1..1d7f38fa 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt @@ -32,13 +32,16 @@ data class WatermarkOptions( val showIso: Boolean = true, val showShutterSpeed: Boolean = true, val showDate: Boolean = false, - val customText: String = "", val outputQuality: Int = 100, val useDarkTheme: Boolean = false, val moveToTop: Boolean = false, val leftAlignOverlay: Boolean = false, val brandTextSize: Int = 50, - val dataTextSize: Int = 50 + val dataTextSize: Int = 50, + val showCustomText: Boolean = false, + val customText: String = "", + val customTextSize: Int = 50, + val padding: Int = 50 ) class WatermarkEngine( @@ -141,7 +144,7 @@ class WatermarkEngine( setShadowLayer(4f, 2f, 2f, shadowColor) } - val margin = bitmap.width * 0.05f + val margin = bitmap.width * (options.padding / 1000f) var yPos = bitmap.height - margin // Apply scaling @@ -169,11 +172,34 @@ class WatermarkEngine( drawExifRow(canvas, row, xPos, yPos, paint, shadowColor) - yPos -= rowHeight * 1.5f + 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 { @@ -209,12 +235,13 @@ class WatermarkEngine( 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 * 0.05f + val margin = bitmap.width * (options.padding / 1000f) // 0 to 10% val maxAvailableWidth = if (options.showDeviceBrand) { (bitmap.width - margin * 2) * 0.6f @@ -233,14 +260,32 @@ class WatermarkEngine( } } - // Dynamic Height Calculation - val requiredHeight = max( - brandPaint.textSize * 2.5f, - totalExifHeight + (exifPaint.textSize * 2f) - ).roundToInt() - - val finalFrameHeight = max(baseFrameHeight, requiredHeight) + // 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) @@ -284,12 +329,39 @@ class WatermarkEngine( ) { - // Brand on Left + // 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) - // Vertical center: centerY + half text height (as baseline) - val brandY = centerY + (brandPaint.textSize / 3f) - canvas.drawText(brandString, margin, brandY, brandPaint) + 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 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 index 6293e993..c40c7699 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt @@ -27,6 +27,10 @@ class WatermarkRepository( 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") val watermarkOptions: Flow = context.dataStore.data .map { preferences -> @@ -50,7 +54,11 @@ class WatermarkRepository( 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 + 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 ) } @@ -101,4 +109,16 @@ class WatermarkRepository( 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 } + } } 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 index fedbce4f..16dbe2bf 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -66,6 +67,7 @@ fun WatermarkScreen( 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() @@ -421,6 +423,57 @@ fun WatermarkScreen( 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 { + com.sameerasw.essentials.utils.HapticUtil.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( @@ -496,6 +549,22 @@ fun WatermarkScreen( valueFormatter = { "${it.toInt()}%" } ) } + + // 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 + com.sameerasw.essentials.utils.HapticUtil.performSliderHaptic(view) + }, + onValueChangeFinished = { viewModel.setPadding(paddingValue.toInt()) }, + valueRange = 0f..100f, + increment = 5f, + valueFormatter = { "${it.toInt()}%" } + ) } // Bottom spacing for scrolling @@ -580,5 +649,110 @@ fun WatermarkScreen( } } } + + 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) } + var draftSize by androidx.compose.runtime.remember { androidx.compose.runtime.mutableFloatStateOf(options.customTextSize.toFloat()) } + + 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)) + + // Size Slider + val density = androidx.compose.ui.platform.LocalDensity.current + Text( + text = stringResource(R.string.watermark_text_size_custom), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + androidx.compose.material3.Slider( + value = draftSize, + onValueChange = { + draftSize = it + performSliderHaptic(view) + }, + valueRange = 0f..100f + ) + Text( + text = "${draftSize.toInt()}%", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.align(Alignment.End), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // 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, draftSize.toInt()) + showCustomTextSheet = false + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.action_save_changes)) + } + } + } + } + } } } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt index 2025ba04..ddcd15fd 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt @@ -207,6 +207,20 @@ class WatermarkViewModel( } } + fun setCustomTextSettings(show: Boolean, text: String, size: Int) { + viewModelScope.launch { + watermarkRepository.updateCustomTextSettings(show, text, size) + previewSourceBitmap?.let { updatePreview() } + } + } + + fun setPadding(padding: Int) { + viewModelScope.launch { + watermarkRepository.updatePadding(padding) + previewSourceBitmap?.let { updatePreview() } + } + } + fun saveImage(uri: Uri) { viewModelScope.launch { _uiState.value = WatermarkUiState.Processing 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/values/strings.xml b/app/src/main/res/values/strings.xml index 52a4dae9..0366b0de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -346,6 +346,12 @@ Align Left Brand Size Data Size + Text Size + Custom Text + Enter your text... + Custom Text Settings + Spacing + Save Changes Widget Haptic feedback From c60f69ed1bcbbc2011e87637ccfa9b2e0df47481 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 23 Jan 2026 17:36:51 +0530 Subject: [PATCH 5/7] Border for watermark --- .../domain/watermark/WatermarkEngine.kt | 86 ++++++++++++++++++- .../domain/watermark/WatermarkRepository.kt | 14 ++- .../composables/watermark/WatermarkScreen.kt | 58 +++++++++++-- .../viewmodels/WatermarkViewModel.kt | 14 +++ app/src/main/res/values/strings.xml | 4 +- 5 files changed, 162 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt index 1d7f38fa..52ccab60 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt @@ -7,6 +7,9 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Rect +import android.graphics.RectF +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode import android.graphics.Typeface import android.net.Uri import com.sameerasw.essentials.R @@ -41,7 +44,9 @@ data class WatermarkOptions( val showCustomText: Boolean = false, val customText: String = "", val customTextSize: Int = 50, - val padding: Int = 50 + val padding: Int = 50, + val borderStroke: Int = 0, + val borderCorner: Int = 0 ) class WatermarkEngine( @@ -126,10 +131,12 @@ class WatermarkEngine( suspend fun processBitmap(bitmap: Bitmap, uri: Uri, options: WatermarkOptions): Bitmap = withContext(Dispatchers.Default) { val exifData = metadataProvider.extractExif(uri) - when (options.style) { + val result = when (options.style) { WatermarkStyle.OVERLAY -> drawOverlay(bitmap, exifData, options) WatermarkStyle.FRAME -> drawFrame(bitmap, exifData, options) } + + applyBorder(result, options) } private fun drawOverlay(bitmap: Bitmap, exifData: ExifData, options: WatermarkOptions): Bitmap { @@ -294,10 +301,28 @@ class WatermarkEngine( // 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(bitmap, 0f, finalFrameHeight.toFloat(), null) + canvas.drawBitmap(sourceToDraw, 0f, finalFrameHeight.toFloat(), null) // Draw Text in "Forehead" val centerY = finalFrameHeight / 2f @@ -308,7 +333,7 @@ class WatermarkEngine( } else { // Draw Image at 0,0 - canvas.drawBitmap(bitmap, 0f, 0f, null) + canvas.drawBitmap(sourceToDraw, 0f, 0f, null) // Draw Text in "Chin" val centerY = bitmap.height + (finalFrameHeight / 2f) @@ -317,6 +342,10 @@ class WatermarkEngine( brandPaint, exifPaint, exifRows, bitmap.width ) } + + if (sourceToDraw != bitmap) { + sourceToDraw.recycle() + } return finalBitmap } @@ -592,4 +621,53 @@ class WatermarkEngine( 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 useDark = options.useDarkTheme + val bgColor = if (useDark) Color.BLACK else Color.WHITE + + canvas.drawColor(bgColor) + + canvas.drawBitmap(roundedBitmap, strokeWidth.toFloat(), strokeWidth.toFloat(), null) + + if (roundedBitmap != output) roundedBitmap.recycle() + output + } else { + roundedBitmap + } + + return finalBitmap + } } 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 index c40c7699..190aa3ee 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt @@ -31,6 +31,8 @@ class WatermarkRepository( 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 -> @@ -58,7 +60,9 @@ class WatermarkRepository( showCustomText = preferences[PREF_SHOW_CUSTOM_TEXT] ?: false, customText = preferences[PREF_CUSTOM_TEXT] ?: "", customTextSize = preferences[PREF_CUSTOM_TEXT_SIZE] ?: 50, - padding = preferences[PREF_PADDING] ?: 50 + padding = preferences[PREF_PADDING] ?: 50, + borderStroke = preferences[PREF_BORDER_STROKE] ?: 0, + borderCorner = preferences[PREF_BORDER_CORNER] ?: 0 ) } @@ -121,4 +125,12 @@ class WatermarkRepository( suspend fun updatePadding(padding: Int) { context.dataStore.edit { it[PREF_PADDING] = padding } } + + 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/composables/watermark/WatermarkScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/watermark/WatermarkScreen.kt index 16dbe2bf..1339f5c8 100644 --- 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 @@ -218,7 +218,7 @@ fun WatermarkScreen( val screenHeightDp = configuration.screenHeightDp.dp val maxPreviewHeightDp = screenHeightDp * 0.6f - val minPreviewHeightDp = screenHeightDp * 0.4f + val minPreviewHeightDp = screenHeightDp * 0.3f val maxPx = with(density) { maxPreviewHeightDp.toPx() } val minPx = with(density) { minPreviewHeightDp.toPx() } @@ -271,8 +271,8 @@ fun WatermarkScreen( .fillMaxWidth() .height(with(density) { previewHeightPx.toDp() }) .padding(16.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .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) { @@ -434,7 +434,7 @@ fun WatermarkScreen( ) .heightIn(min = 56.dp) .clickable { - com.sameerasw.essentials.utils.HapticUtil.performUIHaptic(view) + performUIHaptic(view) showCustomTextSheet = true } .padding(12.dp), @@ -485,7 +485,7 @@ fun WatermarkScreen( ) .heightIn(min = 56.dp) // Match standard item height .clickable { - com.sameerasw.essentials.utils.HapticUtil.performUIHaptic(view) + performUIHaptic(view) showExifSheet = true } .padding(12.dp), @@ -524,7 +524,7 @@ fun WatermarkScreen( value = brandSize, onValueChange = { brandSize = it - com.sameerasw.essentials.utils.HapticUtil.performSliderHaptic(view) + performSliderHaptic(view) }, onValueChangeFinished = { viewModel.setBrandTextSize(brandSize.toInt()) }, valueRange = 0f..100f, @@ -541,7 +541,7 @@ fun WatermarkScreen( value = dataSize, onValueChange = { dataSize = it - com.sameerasw.essentials.utils.HapticUtil.performSliderHaptic(view) + performSliderHaptic(view) }, onValueChangeFinished = { viewModel.setDataTextSize(dataSize.toInt()) }, valueRange = 0f..100f, @@ -558,7 +558,7 @@ fun WatermarkScreen( value = paddingValue, onValueChange = { paddingValue = it - com.sameerasw.essentials.utils.HapticUtil.performSliderHaptic(view) + performSliderHaptic(view) }, onValueChangeFinished = { viewModel.setPadding(paddingValue.toInt()) }, valueRange = 0f..100f, @@ -567,6 +567,48 @@ fun WatermarkScreen( ) } + // 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()}%" } + ) + } + // Bottom spacing for scrolling Spacer(Modifier.height(24.dp)) } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt index ddcd15fd..fe6f2131 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt @@ -221,6 +221,20 @@ class WatermarkViewModel( } } + 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0366b0de..4e46b1f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -324,7 +324,7 @@ Secure apps with biometrics Freeze Disable rarely used apps - Watermarks + Watermark Add EXIF data and logos to photos Style Overlay @@ -351,6 +351,8 @@ Enter your text... Custom Text Settings Spacing + Border Width + Round Corners Save Changes From f2ad74d2e34c789612819b114376efd8dea8a275 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 23 Jan 2026 17:44:59 +0530 Subject: [PATCH 6/7] Updated font size section in watermark layout --- .../domain/watermark/WatermarkRepository.kt | 4 + .../composables/watermark/WatermarkScreen.kt | 128 ++++++++++-------- .../viewmodels/WatermarkViewModel.kt | 7 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 82 insertions(+), 58 deletions(-) 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 index 190aa3ee..c509dc13 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt @@ -126,6 +126,10 @@ class WatermarkRepository( 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 } } 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 index 1339f5c8..701d2ebc 100644 --- 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 @@ -515,40 +515,7 @@ fun WatermarkScreen( ) } - // Text Size Sliders - 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()}%" } - ) - } // Spacing Slider var paddingValue by androidx.compose.runtime.remember(options.padding) { androidx.compose.runtime.mutableFloatStateOf(options.padding.toFloat()) } @@ -567,6 +534,74 @@ fun WatermarkScreen( ) } + + // 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", @@ -698,7 +733,6 @@ fun WatermarkScreen( // 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) } - var draftSize by androidx.compose.runtime.remember { androidx.compose.runtime.mutableFloatStateOf(options.customTextSize.toFloat()) } androidx.compose.material3.ModalBottomSheet( onDismissRequest = { showCustomTextSheet = false }, @@ -739,30 +773,8 @@ fun WatermarkScreen( modifier = Modifier.fillMaxWidth(), singleLine = true ) - + Spacer(modifier = Modifier.height(16.dp)) - - // Size Slider - val density = androidx.compose.ui.platform.LocalDensity.current - Text( - text = stringResource(R.string.watermark_text_size_custom), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface - ) - androidx.compose.material3.Slider( - value = draftSize, - onValueChange = { - draftSize = it - performSliderHaptic(view) - }, - valueRange = 0f..100f - ) - Text( - text = "${draftSize.toInt()}%", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.align(Alignment.End), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } } @@ -785,7 +797,7 @@ fun WatermarkScreen( Button( onClick = { performUIHaptic(view) - viewModel.setCustomTextSettings(isEnabled, draftText, draftSize.toInt()) + viewModel.setCustomTextSettings(isEnabled, draftText, options.customTextSize) // preserve size showCustomTextSheet = false }, modifier = Modifier.weight(1f) diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt index fe6f2131..5c1bc467 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt @@ -214,6 +214,13 @@ class WatermarkViewModel( } } + fun setCustomTextSize(size: Int) { + viewModelScope.launch { + watermarkRepository.updateCustomTextSize(size) + previewSourceBitmap?.let { updatePreview() } + } + } + fun setPadding(padding: Int) { viewModelScope.launch { watermarkRepository.updatePadding(padding) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e46b1f1..53fe1466 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -347,6 +347,7 @@ Brand Size Data Size Text Size + Font Size Custom Text Enter your text... Custom Text Settings From 8a12f0c0ca4bbe784dcd6b1986e0fff2f559f990 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 23 Jan 2026 18:12:59 +0530 Subject: [PATCH 7/7] Material You color for watermark --- .../domain/watermark/WatermarkEngine.kt | 79 +++++++++++-- .../domain/watermark/WatermarkRepository.kt | 18 ++- .../composables/watermark/WatermarkScreen.kt | 104 +++++++++++++++++- .../viewmodels/WatermarkViewModel.kt | 40 ++++++- .../main/res/drawable/rounded_image_24.xml | 5 + app/src/main/res/values/strings.xml | 5 + 6 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_image_24.xml diff --git a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt index 52ccab60..a158e316 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkEngine.kt @@ -25,6 +25,13 @@ enum class WatermarkStyle { FRAME } +enum class ColorMode { + LIGHT, + DARK, + ACCENT_LIGHT, + ACCENT_DARK +} + data class WatermarkOptions( val style: WatermarkStyle = WatermarkStyle.FRAME, val showDeviceBrand: Boolean = true, @@ -36,7 +43,8 @@ data class WatermarkOptions( val showShutterSpeed: Boolean = true, val showDate: Boolean = false, val outputQuality: Int = 100, - val useDarkTheme: Boolean = false, + val colorMode: ColorMode = ColorMode.LIGHT, + val accentColor: Int = android.graphics.Color.GRAY, val moveToTop: Boolean = false, val leftAlignOverlay: Boolean = false, val brandTextSize: Int = 50, @@ -141,12 +149,14 @@ class WatermarkEngine( private fun drawOverlay(bitmap: Bitmap, exifData: ExifData, options: WatermarkOptions): Bitmap { val canvas = Canvas(bitmap) - val useDark = options.useDarkTheme - val textColor = if (useDark) Color.BLACK else Color.WHITE - val shadowColor = if (useDark) Color.WHITE else Color.BLACK - + + // Derive Colors + val colors = deriveColors(options) + val shadowColor = colors.shadowColor + val overlayTextColor = colors.overlayTextColor + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = textColor + color = overlayTextColor textSize = bitmap.width * 0.03f // 3% of width setShadowLayer(4f, 2f, 2f, shadowColor) } @@ -227,10 +237,11 @@ class WatermarkEngine( private fun drawFrame(bitmap: Bitmap, exifData: ExifData, options: WatermarkOptions): Bitmap { var baseFrameHeight = (bitmap.height * 0.10f).roundToInt() - val useDark = options.useDarkTheme - val bgColor = if (useDark) Color.BLACK else Color.WHITE - val textColor = if (useDark) Color.WHITE else Color.BLACK - val secondaryTextColor = if (useDark) Color.LTGRAY else Color.GRAY + // 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 @@ -655,8 +666,8 @@ class WatermarkEngine( val output = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(output) - val useDark = options.useDarkTheme - val bgColor = if (useDark) Color.BLACK else Color.WHITE + val colors = deriveColors(options) + val bgColor = colors.bgColor canvas.drawColor(bgColor) @@ -670,4 +681,48 @@ class WatermarkEngine( 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 index c509dc13..12a8a622 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/watermark/WatermarkRepository.kt @@ -22,7 +22,8 @@ class WatermarkRepository( 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_USE_DARK_THEME = booleanPreferencesKey("use_dark_theme") + 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") @@ -52,7 +53,12 @@ class WatermarkRepository( showIso = preferences[PREF_SHOW_ISO] ?: true, showShutterSpeed = preferences[PREF_SHOW_SHUTTER] ?: true, showDate = preferences[PREF_SHOW_DATE] ?: false, - useDarkTheme = preferences[PREF_USE_DARK_THEME] ?: 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, @@ -94,8 +100,12 @@ class WatermarkRepository( } } - suspend fun updateUseDarkTheme(useDark: Boolean) { - context.dataStore.edit { it[PREF_USE_DARK_THEME] = useDark } + 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) { 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 index 701d2ebc..e7a7cbae 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -45,6 +46,7 @@ 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 @@ -277,8 +279,6 @@ fun WatermarkScreen( performUIHaptic(view) if (initialUri == null) { onPickImage() - } else { - viewModel.toggleContrast() } } .padding(if (initialUri == null) 32.dp else 0.dp), @@ -602,6 +602,8 @@ fun WatermarkScreen( } } + + // Border Section Text( text = "Border", @@ -644,6 +646,49 @@ fun WatermarkScreen( ) } + // 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)) } @@ -810,3 +855,58 @@ fun WatermarkScreen( } } } + +@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 index 5c1bc467..fd3a82a1 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/WatermarkViewModel.kt @@ -8,11 +8,13 @@ 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 @@ -102,6 +104,7 @@ class WatermarkViewModel( if (bitmap != null) { previewSourceBitmap = bitmap + extractColorFromUri(uri) updatePreview() } } catch (e: Exception) { @@ -110,6 +113,39 @@ class WatermarkViewModel( } } + 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 @@ -173,9 +209,9 @@ class WatermarkViewModel( } } - fun toggleContrast() { + fun setColorMode(mode: ColorMode) { viewModelScope.launch { - watermarkRepository.updateUseDarkTheme(!_options.value.useDarkTheme) + watermarkRepository.updateColorMode(mode) } } 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/values/strings.xml b/app/src/main/res/values/strings.xml index 53fe1466..7a423e50 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -354,6 +354,11 @@ Spacing Border Width Round Corners + Color + Light + Dark + Accent Light + Accent Dark Save Changes