Compare commits
10 commits
f53d6f0b1b
...
5d80dcfcbe
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d80dcfcbe | |||
| 780a8ab167 | |||
| 7abb2ea5a0 | |||
| 5cba2fefc9 | |||
| c7fa8f16be | |||
| 41a95885c1 | |||
| ef350e9fb7 | |||
| 6ed3e8e7b5 | |||
| f0249fcd64 | |||
| 593f2c5b1f |
9 changed files with 629 additions and 406 deletions
|
|
@ -18,7 +18,7 @@ A dedicated Android camera app for tilt-shift photography with real-time preview
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Android 8.0 (API 26) or higher
|
- Android 15 (API 35) or higher
|
||||||
- Device with camera
|
- Device with camera
|
||||||
- OpenGL ES 2.0 support
|
- OpenGL ES 2.0 support
|
||||||
|
|
||||||
|
|
@ -66,8 +66,7 @@ app/src/main/java/no/naiv/tiltshift/
|
||||||
│ ├── ZoomControl.kt # Zoom UI component
|
│ ├── ZoomControl.kt # Zoom UI component
|
||||||
│ └── LensSwitcher.kt # Lens selection UI
|
│ └── LensSwitcher.kt # Lens selection UI
|
||||||
├── storage/
|
├── storage/
|
||||||
│ ├── PhotoSaver.kt # MediaStore integration
|
│ └── PhotoSaver.kt # MediaStore integration & EXIF handling
|
||||||
│ └── ExifWriter.kt # EXIF metadata handling
|
|
||||||
└── util/
|
└── util/
|
||||||
├── OrientationDetector.kt
|
├── OrientationDetector.kt
|
||||||
├── LocationProvider.kt
|
├── LocationProvider.kt
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package no.naiv.tiltshift.camera
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.util.Log
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import androidx.camera.core.Camera
|
import androidx.camera.core.Camera
|
||||||
|
|
@ -24,6 +25,10 @@ import java.util.concurrent.Executor
|
||||||
*/
|
*/
|
||||||
class CameraManager(private val context: Context) {
|
class CameraManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CameraManager"
|
||||||
|
}
|
||||||
|
|
||||||
private var cameraProvider: ProcessCameraProvider? = null
|
private var cameraProvider: ProcessCameraProvider? = null
|
||||||
private var camera: Camera? = null
|
private var camera: Camera? = null
|
||||||
private var preview: Preview? = null
|
private var preview: Preview? = null
|
||||||
|
|
@ -32,6 +37,13 @@ class CameraManager(private val context: Context) {
|
||||||
|
|
||||||
val lensController = LensController()
|
val lensController = LensController()
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
private val _zoomRatio = MutableStateFlow(1.0f)
|
private val _zoomRatio = MutableStateFlow(1.0f)
|
||||||
val zoomRatio: StateFlow<Float> = _zoomRatio.asStateFlow()
|
val zoomRatio: StateFlow<Float> = _zoomRatio.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -82,10 +94,11 @@ class CameraManager(private val context: Context) {
|
||||||
.setResolutionSelector(resolutionSelector)
|
.setResolutionSelector(resolutionSelector)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Image capture use case for high-res photos
|
// Image capture use case
|
||||||
imageCapture = ImageCapture.Builder()
|
val captureBuilder = ImageCapture.Builder()
|
||||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
||||||
.build()
|
|
||||||
|
imageCapture = captureBuilder.build()
|
||||||
|
|
||||||
// Select camera based on front/back preference
|
// Select camera based on front/back preference
|
||||||
val cameraSelector = if (_isFrontCamera.value) {
|
val cameraSelector = if (_isFrontCamera.value) {
|
||||||
|
|
@ -117,8 +130,8 @@ class CameraManager(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Camera binding failed
|
Log.e(TAG, "Camera binding failed", e)
|
||||||
e.printStackTrace()
|
_error.value = "Camera failed: ${e.message}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@ import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import androidx.camera.core.ImageCapture
|
import androidx.camera.core.ImageCapture
|
||||||
import androidx.camera.core.ImageCaptureException
|
import androidx.camera.core.ImageCaptureException
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import no.naiv.tiltshift.effect.BlurMode
|
import no.naiv.tiltshift.effect.BlurMode
|
||||||
import no.naiv.tiltshift.effect.BlurParameters
|
import no.naiv.tiltshift.effect.BlurParameters
|
||||||
import no.naiv.tiltshift.storage.PhotoSaver
|
import no.naiv.tiltshift.storage.PhotoSaver
|
||||||
|
|
@ -28,12 +32,19 @@ class ImageCaptureHandler(
|
||||||
private val photoSaver: PhotoSaver
|
private val photoSaver: PhotoSaver
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ImageCaptureHandler"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the processed bitmap ready for saving, produced inside the
|
* Holds the processed bitmap ready for saving, produced inside the
|
||||||
* camera callback (synchronous CPU work) and consumed afterwards
|
* camera callback (synchronous CPU work) and consumed afterwards
|
||||||
* in the caller's coroutine context.
|
* in the caller's coroutine context.
|
||||||
*/
|
*/
|
||||||
private class ProcessedCapture(val bitmap: Bitmap)
|
private class ProcessedCapture(
|
||||||
|
val originalBitmap: Bitmap,
|
||||||
|
val processedBitmap: Bitmap
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures a photo and applies the tilt-shift effect.
|
* Captures a photo and applies the tilt-shift effect.
|
||||||
|
|
@ -58,26 +69,31 @@ class ImageCaptureHandler(
|
||||||
executor,
|
executor,
|
||||||
object : ImageCapture.OnImageCapturedCallback() {
|
object : ImageCapture.OnImageCapturedCallback() {
|
||||||
override fun onCaptureSuccess(imageProxy: ImageProxy) {
|
override fun onCaptureSuccess(imageProxy: ImageProxy) {
|
||||||
|
var currentBitmap: Bitmap? = null
|
||||||
try {
|
try {
|
||||||
val imageRotation = imageProxy.imageInfo.rotationDegrees
|
val imageRotation = imageProxy.imageInfo.rotationDegrees
|
||||||
|
|
||||||
var bitmap = imageProxyToBitmap(imageProxy)
|
currentBitmap = imageProxyToBitmap(imageProxy)
|
||||||
imageProxy.close()
|
imageProxy.close()
|
||||||
|
|
||||||
if (bitmap == null) {
|
if (currentBitmap == null) {
|
||||||
continuation.resume(
|
continuation.resume(
|
||||||
SaveResult.Error("Failed to convert image") as Any
|
SaveResult.Error("Failed to convert image") as Any
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera)
|
currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera)
|
||||||
|
|
||||||
val processedBitmap = applyTiltShiftEffect(bitmap, blurParams)
|
val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams)
|
||||||
bitmap.recycle()
|
// Keep originalBitmap alive — both are recycled after saving
|
||||||
|
val original = currentBitmap
|
||||||
|
currentBitmap = null
|
||||||
|
|
||||||
continuation.resume(ProcessedCapture(processedBitmap))
|
continuation.resume(ProcessedCapture(original, processedBitmap))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Image processing failed", e)
|
||||||
|
currentBitmap?.recycle()
|
||||||
continuation.resume(
|
continuation.resume(
|
||||||
SaveResult.Error("Capture failed: ${e.message}", e) as Any
|
SaveResult.Error("Capture failed: ${e.message}", e) as Any
|
||||||
)
|
)
|
||||||
|
|
@ -95,22 +111,46 @@ class ImageCaptureHandler(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: save to disk in the caller's coroutine context (suspend-safe)
|
// Phase 2: save both original and processed to disk (suspend-safe)
|
||||||
if (captureResult is ProcessedCapture) {
|
if (captureResult is ProcessedCapture) {
|
||||||
return try {
|
return try {
|
||||||
photoSaver.saveBitmap(
|
val thumbnail = createThumbnail(captureResult.processedBitmap)
|
||||||
captureResult.bitmap,
|
val result = photoSaver.saveBitmapPair(
|
||||||
ExifInterface.ORIENTATION_NORMAL,
|
original = captureResult.originalBitmap,
|
||||||
location
|
processed = captureResult.processedBitmap,
|
||||||
|
orientation = ExifInterface.ORIENTATION_NORMAL,
|
||||||
|
location = location
|
||||||
)
|
)
|
||||||
|
if (result is SaveResult.Success) {
|
||||||
|
result.copy(thumbnail = thumbnail)
|
||||||
|
} else {
|
||||||
|
thumbnail?.recycle()
|
||||||
|
result
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
captureResult.bitmap.recycle()
|
captureResult.originalBitmap.recycle()
|
||||||
|
captureResult.processedBitmap.recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return captureResult as SaveResult
|
return captureResult as SaveResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a small thumbnail copy of a bitmap for in-app preview.
|
||||||
|
*/
|
||||||
|
private fun createThumbnail(source: Bitmap, maxSize: Int = 160): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val scale = maxSize.toFloat() / maxOf(source.width, source.height)
|
||||||
|
val width = (source.width * scale).toInt()
|
||||||
|
val height = (source.height * scale).toInt()
|
||||||
|
Bitmap.createScaledBitmap(source, width, height, true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to create thumbnail", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotates a bitmap to the correct orientation.
|
* Rotates a bitmap to the correct orientation.
|
||||||
*/
|
*/
|
||||||
|
|
@ -149,66 +189,200 @@ class ImageCaptureHandler(
|
||||||
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a gallery image and applies EXIF rotation, returning the bitmap for preview.
|
||||||
|
* The caller owns the returned bitmap and is responsible for recycling it.
|
||||||
|
*/
|
||||||
|
suspend fun loadGalleryImage(imageUri: Uri): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bitmap = loadBitmapFromUri(imageUri)
|
||||||
|
?: return@withContext null
|
||||||
|
applyExifRotation(imageUri, bitmap)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load gallery image for preview", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes an existing image from the gallery through the tilt-shift pipeline.
|
||||||
|
* Loads the image, applies EXIF rotation, processes the effect, and saves both versions.
|
||||||
|
*/
|
||||||
|
suspend fun processExistingImage(
|
||||||
|
imageUri: Uri,
|
||||||
|
blurParams: BlurParameters,
|
||||||
|
location: Location?
|
||||||
|
): SaveResult = withContext(Dispatchers.IO) {
|
||||||
|
var originalBitmap: Bitmap? = null
|
||||||
|
var processedBitmap: Bitmap? = null
|
||||||
|
try {
|
||||||
|
originalBitmap = loadBitmapFromUri(imageUri)
|
||||||
|
?: return@withContext SaveResult.Error("Failed to load image")
|
||||||
|
|
||||||
|
originalBitmap = applyExifRotation(imageUri, originalBitmap)
|
||||||
|
|
||||||
|
processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams)
|
||||||
|
|
||||||
|
val thumbnail = createThumbnail(processedBitmap)
|
||||||
|
|
||||||
|
val result = photoSaver.saveBitmapPair(
|
||||||
|
original = originalBitmap,
|
||||||
|
processed = processedBitmap,
|
||||||
|
orientation = ExifInterface.ORIENTATION_NORMAL,
|
||||||
|
location = location
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result is SaveResult.Success) {
|
||||||
|
result.copy(thumbnail = thumbnail)
|
||||||
|
} else {
|
||||||
|
thumbnail?.recycle()
|
||||||
|
result
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Gallery image processing failed", e)
|
||||||
|
SaveResult.Error("Processing failed: ${e.message}", e)
|
||||||
|
} finally {
|
||||||
|
originalBitmap?.recycle()
|
||||||
|
processedBitmap?.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a bitmap from a content URI.
|
||||||
|
*/
|
||||||
|
private fun loadBitmapFromUri(uri: Uri): Bitmap? {
|
||||||
|
return try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
BitmapFactory.decodeStream(stream)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load bitmap from URI", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads EXIF orientation from a content URI and applies the
|
||||||
|
* required rotation/flip to the bitmap.
|
||||||
|
*/
|
||||||
|
private fun applyExifRotation(uri: Uri, bitmap: Bitmap): Bitmap {
|
||||||
|
val orientation = try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
ExifInterface(stream).getAttributeInt(
|
||||||
|
ExifInterface.TAG_ORIENTATION,
|
||||||
|
ExifInterface.ORIENTATION_NORMAL
|
||||||
|
)
|
||||||
|
} ?: ExifInterface.ORIENTATION_NORMAL
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to read EXIF orientation", e)
|
||||||
|
ExifInterface.ORIENTATION_NORMAL
|
||||||
|
}
|
||||||
|
|
||||||
|
val matrix = Matrix()
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
|
matrix.postRotate(90f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
|
matrix.postRotate(270f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
else -> return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
val rotated = Bitmap.createBitmap(
|
||||||
|
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
|
||||||
|
)
|
||||||
|
if (rotated != bitmap) {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
return rotated
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies tilt-shift blur effect to a bitmap.
|
* Applies tilt-shift blur effect to a bitmap.
|
||||||
* Supports both linear and radial modes.
|
* Supports both linear and radial modes.
|
||||||
|
*
|
||||||
|
* All intermediate bitmaps are tracked and recycled in a finally block
|
||||||
|
* so that an OOM or other exception does not leak native memory.
|
||||||
*/
|
*/
|
||||||
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
|
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
|
||||||
val width = source.width
|
val width = source.width
|
||||||
val height = source.height
|
val height = source.height
|
||||||
|
|
||||||
// Create output bitmap
|
var result: Bitmap? = null
|
||||||
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
var scaled: Bitmap? = null
|
||||||
|
var blurred: Bitmap? = null
|
||||||
|
var blurredFullSize: Bitmap? = null
|
||||||
|
var mask: Bitmap? = null
|
||||||
|
|
||||||
// For performance, we use a scaled-down version for blur and composite
|
try {
|
||||||
val scaleFactor = 4 // Blur a 1/4 size image for speed
|
result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
val blurredWidth = width / scaleFactor
|
|
||||||
val blurredHeight = height / scaleFactor
|
|
||||||
|
|
||||||
// Create scaled bitmap for blur
|
val scaleFactor = 4
|
||||||
val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
val blurredWidth = width / scaleFactor
|
||||||
|
val blurredHeight = height / scaleFactor
|
||||||
|
|
||||||
// Apply stack blur (fast approximation)
|
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
||||||
val blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
|
|
||||||
scaled.recycle()
|
|
||||||
|
|
||||||
// Scale blurred back up
|
blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
|
||||||
val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
|
scaled.recycle()
|
||||||
blurred.recycle()
|
scaled = null
|
||||||
|
|
||||||
// Create gradient mask based on tilt-shift parameters
|
blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
|
||||||
val mask = createGradientMask(width, height, params)
|
blurred.recycle()
|
||||||
|
blurred = null
|
||||||
|
|
||||||
// Composite: blend original with blurred based on mask
|
mask = createGradientMask(width, height, params)
|
||||||
val pixels = IntArray(width * height)
|
|
||||||
val blurredPixels = IntArray(width * height)
|
|
||||||
val maskPixels = IntArray(width * height)
|
|
||||||
|
|
||||||
source.getPixels(pixels, 0, width, 0, 0, width, height)
|
// Composite: blend original with blurred based on mask
|
||||||
blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height)
|
val pixels = IntArray(width * height)
|
||||||
mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
|
val blurredPixels = IntArray(width * height)
|
||||||
|
val maskPixels = IntArray(width * height)
|
||||||
|
|
||||||
blurredFullSize.recycle()
|
source.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||||
mask.recycle()
|
blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height)
|
||||||
|
mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
|
||||||
|
|
||||||
for (i in pixels.indices) {
|
blurredFullSize.recycle()
|
||||||
val maskAlpha = (maskPixels[i] and 0xFF) / 255f
|
blurredFullSize = null
|
||||||
val origR = (pixels[i] shr 16) and 0xFF
|
mask.recycle()
|
||||||
val origG = (pixels[i] shr 8) and 0xFF
|
mask = null
|
||||||
val origB = pixels[i] and 0xFF
|
|
||||||
val blurR = (blurredPixels[i] shr 16) and 0xFF
|
|
||||||
val blurG = (blurredPixels[i] shr 8) and 0xFF
|
|
||||||
val blurB = blurredPixels[i] and 0xFF
|
|
||||||
|
|
||||||
val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt()
|
for (i in pixels.indices) {
|
||||||
val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt()
|
val maskAlpha = (maskPixels[i] and 0xFF) / 255f
|
||||||
val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt()
|
val origR = (pixels[i] shr 16) and 0xFF
|
||||||
|
val origG = (pixels[i] shr 8) and 0xFF
|
||||||
|
val origB = pixels[i] and 0xFF
|
||||||
|
val blurR = (blurredPixels[i] shr 16) and 0xFF
|
||||||
|
val blurG = (blurredPixels[i] shr 8) and 0xFF
|
||||||
|
val blurB = blurredPixels[i] and 0xFF
|
||||||
|
|
||||||
pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt()
|
||||||
|
val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt()
|
||||||
|
val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt()
|
||||||
|
|
||||||
|
pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
result.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||||
|
|
||||||
|
val output = result
|
||||||
|
result = null // prevent finally from recycling the returned bitmap
|
||||||
|
return output
|
||||||
|
} finally {
|
||||||
|
result?.recycle()
|
||||||
|
scaled?.recycle()
|
||||||
|
blurred?.recycle()
|
||||||
|
blurredFullSize?.recycle()
|
||||||
|
mask?.recycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
result.setPixels(pixels, 0, width, 0, 0, width, height)
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,4 @@ class LensController {
|
||||||
return availableLenses[currentLensIndex]
|
return availableLenses[currentLensIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Common zoom levels that can be achieved through digital zoom.
|
|
||||||
* These are presented as quick-select buttons.
|
|
||||||
*/
|
|
||||||
fun getZoomPresets(): List<Float> {
|
|
||||||
return listOf(0.5f, 1.0f, 2.0f, 5.0f)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
package no.naiv.tiltshift.storage
|
|
||||||
|
|
||||||
import android.location.Location
|
|
||||||
import androidx.exifinterface.media.ExifInterface
|
|
||||||
import java.io.File
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes EXIF metadata to captured images.
|
|
||||||
*/
|
|
||||||
class ExifWriter {
|
|
||||||
|
|
||||||
private val dateTimeFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes EXIF data to the specified image file.
|
|
||||||
*/
|
|
||||||
fun writeExifData(
|
|
||||||
file: File,
|
|
||||||
orientation: Int,
|
|
||||||
location: Location?,
|
|
||||||
make: String = "Android",
|
|
||||||
model: String = android.os.Build.MODEL
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
val exif = ExifInterface(file)
|
|
||||||
|
|
||||||
// Orientation
|
|
||||||
exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
|
|
||||||
|
|
||||||
// Date/time
|
|
||||||
val dateTime = dateTimeFormat.format(Date())
|
|
||||||
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
|
|
||||||
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
|
|
||||||
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, dateTime)
|
|
||||||
|
|
||||||
// Camera info
|
|
||||||
exif.setAttribute(ExifInterface.TAG_MAKE, make)
|
|
||||||
exif.setAttribute(ExifInterface.TAG_MODEL, model)
|
|
||||||
exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera")
|
|
||||||
|
|
||||||
// GPS location
|
|
||||||
if (location != null) {
|
|
||||||
setLocationExif(exif, location)
|
|
||||||
}
|
|
||||||
|
|
||||||
exif.saveAttributes()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setLocationExif(exif: ExifInterface, location: Location) {
|
|
||||||
// Latitude
|
|
||||||
val latitude = location.latitude
|
|
||||||
val latRef = if (latitude >= 0) "N" else "S"
|
|
||||||
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, convertToDMS(Math.abs(latitude)))
|
|
||||||
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, latRef)
|
|
||||||
|
|
||||||
// Longitude
|
|
||||||
val longitude = location.longitude
|
|
||||||
val lonRef = if (longitude >= 0) "E" else "W"
|
|
||||||
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, convertToDMS(Math.abs(longitude)))
|
|
||||||
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, lonRef)
|
|
||||||
|
|
||||||
// Altitude
|
|
||||||
if (location.hasAltitude()) {
|
|
||||||
val altitude = location.altitude
|
|
||||||
val altRef = if (altitude >= 0) "0" else "1"
|
|
||||||
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, "${Math.abs(altitude).toLong()}/1")
|
|
||||||
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, altRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timestamp
|
|
||||||
val gpsTimeFormat = SimpleDateFormat("HH:mm:ss", Locale.US)
|
|
||||||
val gpsDateFormat = SimpleDateFormat("yyyy:MM:dd", Locale.US)
|
|
||||||
val timestamp = Date(location.time)
|
|
||||||
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, gpsTimeFormat.format(timestamp))
|
|
||||||
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, gpsDateFormat.format(timestamp))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts decimal degrees to DMS (degrees/minutes/seconds) format for EXIF.
|
|
||||||
*/
|
|
||||||
private fun convertToDMS(coordinate: Double): String {
|
|
||||||
val degrees = coordinate.toInt()
|
|
||||||
val minutesDecimal = (coordinate - degrees) * 60
|
|
||||||
val minutes = minutesDecimal.toInt()
|
|
||||||
val seconds = (minutesDecimal - minutes) * 60
|
|
||||||
|
|
||||||
// EXIF format: "degrees/1,minutes/1,seconds/1000"
|
|
||||||
return "$degrees/1,$minutes/1,${(seconds * 1000).toLong()}/1000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,28 +3,30 @@ package no.naiv.tiltshift.storage
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of a photo save operation.
|
* Result of a photo save operation.
|
||||||
*/
|
*/
|
||||||
sealed class SaveResult {
|
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()
|
data class Error(val message: String, val exception: Exception? = null) : SaveResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,7 +35,9 @@ sealed class SaveResult {
|
||||||
*/
|
*/
|
||||||
class PhotoSaver(private val context: Context) {
|
class PhotoSaver(private val context: Context) {
|
||||||
|
|
||||||
private val exifWriter = ExifWriter()
|
companion object {
|
||||||
|
private const val TAG = "PhotoSaver"
|
||||||
|
}
|
||||||
|
|
||||||
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||||
|
|
||||||
|
|
@ -45,100 +49,68 @@ class PhotoSaver(private val context: Context) {
|
||||||
orientation: Int,
|
orientation: Int,
|
||||||
location: Location?
|
location: Location?
|
||||||
): SaveResult = withContext(Dispatchers.IO) {
|
): 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
|
|
||||||
val contentValues = ContentValues().apply {
|
|
||||||
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
|
||||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
|
||||||
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
|
|
||||||
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
|
|
||||||
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")
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
|
|
||||||
// Write EXIF data
|
|
||||||
writeExifToUri(uri, orientation, location)
|
|
||||||
|
|
||||||
// Mark as complete (API 29+)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves a JPEG file (from CameraX ImageCapture) to the gallery.
|
* 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 saveJpegFile(
|
suspend fun saveBitmapPair(
|
||||||
sourceFile: File,
|
original: Bitmap,
|
||||||
|
processed: Bitmap,
|
||||||
orientation: Int,
|
orientation: Int,
|
||||||
location: Location?
|
location: Location?
|
||||||
): SaveResult = withContext(Dispatchers.IO) {
|
): SaveResult = withContext(Dispatchers.IO) {
|
||||||
try {
|
val timestamp = fileNameFormat.format(Date())
|
||||||
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
|
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 {
|
val contentValues = ContentValues().apply {
|
||||||
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
||||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||||
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
|
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
|
||||||
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||||
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
|
|
||||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
val uri = contentResolver.insert(
|
val uri = contentResolver.insert(
|
||||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||||
contentValues
|
contentValues
|
||||||
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
|
) ?: return SaveResult.Error("Failed to create MediaStore entry")
|
||||||
|
|
||||||
// Copy file to MediaStore
|
|
||||||
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
sourceFile.inputStream().use { inputStream ->
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
|
||||||
inputStream.copyTo(outputStream)
|
} ?: return SaveResult.Error("Failed to open output stream")
|
||||||
}
|
|
||||||
} ?: return@withContext SaveResult.Error("Failed to open output stream")
|
|
||||||
|
|
||||||
// Write EXIF data
|
|
||||||
writeExifToUri(uri, orientation, location)
|
writeExifToUri(uri, orientation, location)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
contentValues.clear()
|
||||||
contentValues.clear()
|
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||||
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
|
contentResolver.update(uri, contentValues, null, null)
|
||||||
contentResolver.update(uri, contentValues, null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up source file
|
|
||||||
sourceFile.delete()
|
|
||||||
|
|
||||||
val path = getPathFromUri(uri)
|
val path = getPathFromUri(uri)
|
||||||
SaveResult.Success(uri, path)
|
SaveResult.Success(uri, path)
|
||||||
|
|
@ -169,7 +141,7 @@ class PhotoSaver(private val context: Context) {
|
||||||
exif.saveAttributes()
|
exif.saveAttributes()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
Log.w(TAG, "Failed to write EXIF data", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
package no.naiv.tiltshift.ui
|
package no.naiv.tiltshift.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
|
import android.net.Uri
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.systemGestureExclusion
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -25,10 +30,14 @@ import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.FlipCameraAndroid
|
import androidx.compose.material.icons.filled.FlipCameraAndroid
|
||||||
|
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
|
@ -46,8 +55,11 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -97,13 +109,53 @@ fun CameraScreen(
|
||||||
var showSaveError by remember { mutableStateOf<String?>(null) }
|
var showSaveError by remember { mutableStateOf<String?>(null) }
|
||||||
var showControls by remember { mutableStateOf(false) }
|
var showControls by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Thumbnail state for last captured photo
|
||||||
|
var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
|
||||||
|
// Gallery preview mode: non-null means we're previewing a gallery image
|
||||||
|
var galleryBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
var galleryImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
val isGalleryPreview = galleryBitmap != null
|
||||||
|
|
||||||
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
|
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
|
||||||
var currentLocation by remember { mutableStateOf<Location?>(null) }
|
var currentLocation by remember { mutableStateOf<Location?>(null) }
|
||||||
|
|
||||||
|
// Gallery picker: load image for interactive preview before processing
|
||||||
|
val galleryLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.PickVisualMedia()
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null && !isCapturing && !isGalleryPreview) {
|
||||||
|
scope.launch {
|
||||||
|
val bitmap = captureHandler.loadGalleryImage(uri)
|
||||||
|
if (bitmap != null) {
|
||||||
|
galleryBitmap = bitmap
|
||||||
|
galleryImageUri = uri
|
||||||
|
} else {
|
||||||
|
haptics.error()
|
||||||
|
showSaveError = "Failed to load image"
|
||||||
|
delay(2000)
|
||||||
|
showSaveError = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val zoomRatio by cameraManager.zoomRatio.collectAsState()
|
val zoomRatio by cameraManager.zoomRatio.collectAsState()
|
||||||
val minZoom by cameraManager.minZoomRatio.collectAsState()
|
val minZoom by cameraManager.minZoomRatio.collectAsState()
|
||||||
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
|
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
|
||||||
val isFrontCamera by cameraManager.isFrontCamera.collectAsState()
|
val isFrontCamera by cameraManager.isFrontCamera.collectAsState()
|
||||||
|
val cameraError by cameraManager.error.collectAsState()
|
||||||
|
|
||||||
|
// Show camera errors via the existing error UI
|
||||||
|
LaunchedEffect(cameraError) {
|
||||||
|
cameraError?.let { message ->
|
||||||
|
showSaveError = message
|
||||||
|
cameraManager.clearError()
|
||||||
|
delay(2000)
|
||||||
|
showSaveError = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Collect orientation updates
|
// Collect orientation updates
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -143,6 +195,8 @@ fun CameraScreen(
|
||||||
onDispose {
|
onDispose {
|
||||||
cameraManager.release()
|
cameraManager.release()
|
||||||
renderer?.release()
|
renderer?.release()
|
||||||
|
lastThumbnailBitmap?.recycle()
|
||||||
|
galleryBitmap?.recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,25 +205,39 @@ fun CameraScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
) {
|
) {
|
||||||
// OpenGL Surface for camera preview with effect
|
// Main view: gallery preview image or camera GL surface
|
||||||
AndroidView(
|
if (isGalleryPreview) {
|
||||||
factory = { ctx ->
|
galleryBitmap?.let { bmp ->
|
||||||
GLSurfaceView(ctx).apply {
|
Image(
|
||||||
setEGLContextClientVersion(2)
|
bitmap = bmp.asImageBitmap(),
|
||||||
|
contentDescription = "Gallery preview",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// OpenGL Surface for camera preview with effect
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
GLSurfaceView(ctx).apply {
|
||||||
|
setEGLContextClientVersion(2)
|
||||||
|
|
||||||
val newRenderer = TiltShiftRenderer(ctx) { st ->
|
val newRenderer = TiltShiftRenderer(ctx) { st ->
|
||||||
surfaceTexture = st
|
surfaceTexture = st
|
||||||
|
}
|
||||||
|
renderer = newRenderer
|
||||||
|
|
||||||
|
setRenderer(newRenderer)
|
||||||
|
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
|
||||||
|
|
||||||
|
glSurfaceView = this
|
||||||
}
|
}
|
||||||
renderer = newRenderer
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
setRenderer(newRenderer)
|
)
|
||||||
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
|
}
|
||||||
|
|
||||||
glSurfaceView = this
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tilt-shift overlay (gesture handling + visualization)
|
// Tilt-shift overlay (gesture handling + visualization)
|
||||||
TiltShiftOverlay(
|
TiltShiftOverlay(
|
||||||
|
|
@ -179,8 +247,10 @@ fun CameraScreen(
|
||||||
haptics.tick()
|
haptics.tick()
|
||||||
},
|
},
|
||||||
onZoomChange = { zoomDelta ->
|
onZoomChange = { zoomDelta ->
|
||||||
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
|
if (!isGalleryPreview) {
|
||||||
cameraManager.setZoom(newZoom)
|
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
|
||||||
|
cameraManager.setZoom(newZoom)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
|
@ -197,22 +267,28 @@ fun CameraScreen(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Zoom indicator
|
if (!isGalleryPreview) {
|
||||||
ZoomIndicator(currentZoom = zoomRatio)
|
// Zoom indicator
|
||||||
|
ZoomIndicator(currentZoom = zoomRatio)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.width(1.dp))
|
||||||
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
// Camera flip button
|
if (!isGalleryPreview) {
|
||||||
IconButton(
|
// Camera flip button
|
||||||
onClick = {
|
IconButton(
|
||||||
cameraManager.switchCamera()
|
onClick = {
|
||||||
haptics.click()
|
cameraManager.switchCamera()
|
||||||
|
haptics.click()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FlipCameraAndroid,
|
||||||
|
contentDescription = "Switch Camera",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.FlipCameraAndroid,
|
|
||||||
contentDescription = "Switch Camera",
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle controls button
|
// Toggle controls button
|
||||||
|
|
@ -270,66 +346,197 @@ fun CameraScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.padding(bottom = 24.dp),
|
.padding(bottom = 48.dp)
|
||||||
|
.systemGestureExclusion(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
// Zoom presets (only show for back camera)
|
if (isGalleryPreview) {
|
||||||
if (!isFrontCamera) {
|
// Gallery preview mode: Cancel | Apply
|
||||||
ZoomControl(
|
Row(
|
||||||
currentZoom = zoomRatio,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
minZoom = minZoom,
|
horizontalArrangement = Arrangement.spacedBy(48.dp)
|
||||||
maxZoom = maxZoom,
|
) {
|
||||||
onZoomSelected = { zoom ->
|
// Cancel button
|
||||||
cameraManager.setZoom(zoom)
|
IconButton(
|
||||||
haptics.click()
|
onClick = {
|
||||||
|
galleryBitmap?.recycle()
|
||||||
|
galleryBitmap = null
|
||||||
|
galleryImageUri = null
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0x80000000))
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Cancel",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
// Apply button
|
||||||
}
|
IconButton(
|
||||||
|
onClick = {
|
||||||
// Capture button
|
val uri = galleryImageUri ?: return@IconButton
|
||||||
CaptureButton(
|
if (!isCapturing) {
|
||||||
isCapturing = isCapturing,
|
isCapturing = true
|
||||||
onClick = {
|
haptics.heavyClick()
|
||||||
if (!isCapturing) {
|
scope.launch {
|
||||||
isCapturing = true
|
val result = captureHandler.processExistingImage(
|
||||||
haptics.heavyClick()
|
imageUri = uri,
|
||||||
|
blurParams = blurParams,
|
||||||
scope.launch {
|
location = currentLocation
|
||||||
val imageCapture = cameraManager.imageCapture
|
)
|
||||||
if (imageCapture != null) {
|
when (result) {
|
||||||
val result = captureHandler.capturePhoto(
|
is SaveResult.Success -> {
|
||||||
imageCapture = imageCapture,
|
haptics.success()
|
||||||
executor = cameraManager.getExecutor(),
|
lastThumbnailBitmap?.recycle()
|
||||||
blurParams = blurParams,
|
lastThumbnailBitmap = result.thumbnail
|
||||||
deviceRotation = currentRotation,
|
lastSavedUri = result.uri
|
||||||
location = currentLocation,
|
showSaveSuccess = true
|
||||||
isFrontCamera = isFrontCamera
|
delay(1500)
|
||||||
)
|
showSaveSuccess = false
|
||||||
|
}
|
||||||
when (result) {
|
is SaveResult.Error -> {
|
||||||
is SaveResult.Success -> {
|
haptics.error()
|
||||||
haptics.success()
|
showSaveError = result.message
|
||||||
showSaveSuccess = true
|
delay(2000)
|
||||||
delay(1500)
|
showSaveError = null
|
||||||
showSaveSuccess = false
|
}
|
||||||
}
|
|
||||||
is SaveResult.Error -> {
|
|
||||||
haptics.error()
|
|
||||||
showSaveError = result.message
|
|
||||||
delay(2000)
|
|
||||||
showSaveError = null
|
|
||||||
}
|
}
|
||||||
|
galleryBitmap?.recycle()
|
||||||
|
galleryBitmap = null
|
||||||
|
galleryImageUri = null
|
||||||
|
isCapturing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isCapturing = false
|
},
|
||||||
}
|
enabled = !isCapturing,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFFFFB300))
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "Apply effect",
|
||||||
|
tint = Color.Black,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
} else {
|
||||||
|
// Camera mode: Zoom presets + Gallery | Capture | Spacer
|
||||||
|
// Zoom presets (only show for back camera)
|
||||||
|
if (!isFrontCamera) {
|
||||||
|
ZoomControl(
|
||||||
|
currentZoom = zoomRatio,
|
||||||
|
minZoom = minZoom,
|
||||||
|
maxZoom = maxZoom,
|
||||||
|
onZoomSelected = { zoom ->
|
||||||
|
cameraManager.setZoom(zoom)
|
||||||
|
haptics.click()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gallery button | Capture button | Spacer for symmetry
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
// Gallery picker button
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (!isCapturing) {
|
||||||
|
galleryLauncher.launch(
|
||||||
|
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isCapturing,
|
||||||
|
modifier = Modifier.size(52.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PhotoLibrary,
|
||||||
|
contentDescription = "Pick from gallery",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture button
|
||||||
|
CaptureButton(
|
||||||
|
isCapturing = isCapturing,
|
||||||
|
onClick = {
|
||||||
|
if (!isCapturing) {
|
||||||
|
isCapturing = true
|
||||||
|
haptics.heavyClick()
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
val imageCapture = cameraManager.imageCapture
|
||||||
|
if (imageCapture != null) {
|
||||||
|
val result = captureHandler.capturePhoto(
|
||||||
|
imageCapture = imageCapture,
|
||||||
|
executor = cameraManager.getExecutor(),
|
||||||
|
blurParams = blurParams,
|
||||||
|
deviceRotation = currentRotation,
|
||||||
|
location = currentLocation,
|
||||||
|
isFrontCamera = isFrontCamera
|
||||||
|
)
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is SaveResult.Success -> {
|
||||||
|
haptics.success()
|
||||||
|
lastThumbnailBitmap?.recycle()
|
||||||
|
lastThumbnailBitmap = result.thumbnail
|
||||||
|
lastSavedUri = result.uri
|
||||||
|
showSaveSuccess = true
|
||||||
|
delay(1500)
|
||||||
|
showSaveSuccess = false
|
||||||
|
}
|
||||||
|
is SaveResult.Error -> {
|
||||||
|
haptics.error()
|
||||||
|
showSaveError = result.message
|
||||||
|
delay(2000)
|
||||||
|
showSaveError = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isCapturing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spacer for visual symmetry with gallery button
|
||||||
|
Spacer(modifier = Modifier.size(52.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last captured photo thumbnail (hidden in gallery preview mode)
|
||||||
|
if (!isGalleryPreview) LastPhotoThumbnail(
|
||||||
|
thumbnail = lastThumbnailBitmap,
|
||||||
|
onTap = {
|
||||||
|
lastSavedUri?.let { uri ->
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "image/jpeg")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(bottom = 48.dp, end = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
// Success indicator
|
// Success indicator
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSaveSuccess,
|
visible = showSaveSuccess,
|
||||||
|
|
@ -484,6 +691,7 @@ private fun ControlPanel(
|
||||||
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
|
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -552,3 +760,34 @@ private fun CaptureButton(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounded thumbnail of the last captured photo.
|
||||||
|
* Tapping opens the image in the default photo viewer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LastPhotoThumbnail(
|
||||||
|
thumbnail: Bitmap?,
|
||||||
|
onTap: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = thumbnail != null,
|
||||||
|
enter = fadeIn() + scaleIn(initialScale = 0.6f),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
thumbnail?.let { bmp ->
|
||||||
|
Image(
|
||||||
|
bitmap = bmp.asImageBitmap(),
|
||||||
|
contentDescription = "Last captured photo",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(52.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
|
||||||
|
.clickable(onClick = onTap)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CameraRear
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -90,28 +87,3 @@ private fun LensButton(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple camera flip button (for future front camera support).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CameraFlipButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color(0x80000000))
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.CameraRear,
|
|
||||||
contentDescription = "Switch Camera",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,55 @@
|
||||||
package no.naiv.tiltshift.util
|
package no.naiv.tiltshift.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
import android.os.Vibrator
|
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
import android.view.HapticFeedbackConstants
|
|
||||||
import android.view.View
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides haptic feedback for user interactions.
|
* Provides haptic feedback for user interactions.
|
||||||
*/
|
*/
|
||||||
class HapticFeedback(private val context: Context) {
|
class HapticFeedback(private val context: Context) {
|
||||||
|
|
||||||
private val vibrator: Vibrator by lazy {
|
private val vibrator by lazy {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||||
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
vibratorManager.defaultVibrator
|
||||||
vibratorManager.defaultVibrator
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Light tick for UI feedback (button press, slider change).
|
* Light tick for UI feedback (button press, slider change).
|
||||||
*/
|
*/
|
||||||
fun tick() {
|
fun tick() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||||
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
vibrator.vibrate(10L)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click feedback for confirmations.
|
* Click feedback for confirmations.
|
||||||
*/
|
*/
|
||||||
fun click() {
|
fun click() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
||||||
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
vibrator.vibrate(20L)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heavy click for important actions (photo capture).
|
* Heavy click for important actions (photo capture).
|
||||||
*/
|
*/
|
||||||
fun heavyClick() {
|
fun heavyClick() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))
|
||||||
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
vibrator.vibrate(40L)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Success feedback pattern.
|
* Success feedback pattern.
|
||||||
*/
|
*/
|
||||||
fun success() {
|
fun success() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
val timings = longArrayOf(0, 30, 50, 30)
|
||||||
val timings = longArrayOf(0, 30, 50, 30)
|
val amplitudes = intArrayOf(0, 100, 0, 200)
|
||||||
val amplitudes = intArrayOf(0, 100, 0, 200)
|
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||||
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
vibrator.vibrate(longArrayOf(0, 30, 50, 30), -1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error feedback pattern.
|
* Error feedback pattern.
|
||||||
*/
|
*/
|
||||||
fun error() {
|
fun error() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
val timings = longArrayOf(0, 50, 30, 50, 30, 50)
|
||||||
val timings = longArrayOf(0, 50, 30, 50, 30, 50)
|
val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150)
|
||||||
val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150)
|
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||||
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
vibrator.vibrate(longArrayOf(0, 50, 30, 50, 30, 50), -1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Use system haptic feedback on a View for standard interactions.
|
|
||||||
*/
|
|
||||||
fun performHapticFeedback(view: View, feedbackConstant: Int = HapticFeedbackConstants.VIRTUAL_KEY) {
|
|
||||||
view.performHapticFeedback(feedbackConstant)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue