Add bottom bar gesture exclusion, dual image save, thumbnail preview, and gallery picker

- Increase bottom padding and add systemGestureExclusion to prevent
  accidental navigation gestures from the capture controls
- Save both original and processed images with shared timestamps
  (TILTSHIFT_* and ORIGINAL_*) via new saveBitmapPair() pipeline
- Show animated thumbnail of last captured photo at bottom-right;
  tap opens the image in the default photo viewer
- Add gallery picker button to process existing photos through the
  tilt-shift pipeline with full EXIF rotation support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-03 22:32:11 +01:00
commit 780a8ab167
3 changed files with 355 additions and 57 deletions

View file

@ -21,7 +21,12 @@ import java.util.Locale
* Result of a photo save operation.
*/
sealed class SaveResult {
data class Success(val uri: Uri, val path: String) : SaveResult()
data class Success(
val uri: Uri,
val path: String,
val originalUri: Uri? = null,
val thumbnail: android.graphics.Bitmap? = null
) : SaveResult()
data class Error(val message: String, val exception: Exception? = null) : SaveResult()
}
@ -44,10 +49,44 @@ class PhotoSaver(private val context: Context) {
orientation: Int,
location: Location?
): SaveResult = withContext(Dispatchers.IO) {
try {
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
saveSingleBitmap(fileName, bitmap, orientation, location)
}
// Create content values for MediaStore
/**
* Saves both original and processed bitmaps to the gallery.
* Uses a shared timestamp so paired files sort together.
* Returns the processed image's URI as the primary result.
*/
suspend fun saveBitmapPair(
original: Bitmap,
processed: Bitmap,
orientation: Int,
location: Location?
): SaveResult = withContext(Dispatchers.IO) {
val timestamp = fileNameFormat.format(Date())
val processedFileName = "TILTSHIFT_${timestamp}.jpg"
val originalFileName = "ORIGINAL_${timestamp}.jpg"
val processedResult = saveSingleBitmap(processedFileName, processed, orientation, location)
if (processedResult is SaveResult.Error) return@withContext processedResult
val originalResult = saveSingleBitmap(originalFileName, original, orientation, location)
val originalUri = (originalResult as? SaveResult.Success)?.uri
(processedResult as SaveResult.Success).copy(originalUri = originalUri)
}
/**
* Core save logic: writes a single bitmap to MediaStore with EXIF data.
*/
private fun saveSingleBitmap(
fileName: String,
bitmap: Bitmap,
orientation: Int,
location: Location?
): SaveResult {
return try {
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
@ -57,29 +96,23 @@ class PhotoSaver(private val context: Context) {
put(MediaStore.Images.Media.IS_PENDING, 1)
}
// Insert into MediaStore
val contentResolver = context.contentResolver
val uri = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
) ?: return SaveResult.Error("Failed to create MediaStore entry")
// Write bitmap to output stream
contentResolver.openOutputStream(uri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
} ?: return@withContext SaveResult.Error("Failed to open output stream")
} ?: return SaveResult.Error("Failed to open output stream")
// Write EXIF data
writeExifToUri(uri, orientation, location)
// Mark as complete
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null)
// Get the file path for display
val path = getPathFromUri(uri)
SaveResult.Success(uri, path)
} catch (e: Exception) {
SaveResult.Error("Failed to save photo: ${e.message}", e)