-
Notifications
You must be signed in to change notification settings - Fork 229
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
17 changed files
with
495 additions
and
32 deletions.
There are no files selected for viewing
288 changes: 288 additions & 0 deletions
288
app/src/main/java/ru/tech/imageresizershrinker/crop_screen/CropScreen.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,288 @@ | ||
package ru.tech.imageresizershrinker.crop_screen | ||
|
||
|
||
import android.content.ContentValues | ||
import android.graphics.Bitmap | ||
import android.net.Uri | ||
import android.os.Environment | ||
import android.provider.MediaStore | ||
import androidx.activity.ComponentActivity | ||
import androidx.activity.compose.BackHandler | ||
import androidx.activity.compose.rememberLauncherForActivityResult | ||
import androidx.activity.result.PickVisualMediaRequest | ||
import androidx.activity.result.contract.ActivityResultContracts | ||
import androidx.compose.foundation.layout.* | ||
import androidx.compose.material.icons.Icons | ||
import androidx.compose.material.icons.outlined.Share | ||
import androidx.compose.material.icons.rounded.AddPhotoAlternate | ||
import androidx.compose.material.icons.rounded.ArrowBack | ||
import androidx.compose.material.icons.rounded.ErrorOutline | ||
import androidx.compose.material.icons.rounded.Save | ||
import androidx.compose.material3.* | ||
import androidx.compose.runtime.* | ||
import androidx.compose.runtime.saveable.rememberSaveable | ||
import androidx.compose.ui.Alignment | ||
import androidx.compose.ui.Modifier | ||
import androidx.compose.ui.draw.shadow | ||
import androidx.compose.ui.graphics.asAndroidBitmap | ||
import androidx.compose.ui.graphics.asImageBitmap | ||
import androidx.compose.ui.platform.LocalContext | ||
import androidx.compose.ui.res.stringResource | ||
import androidx.compose.ui.unit.dp | ||
import androidx.lifecycle.viewmodel.compose.viewModel | ||
import com.cookhelper.dynamic.theme.LocalDynamicThemeState | ||
import com.smarttoolfactory.cropper.ImageCropper | ||
import com.smarttoolfactory.cropper.model.OutlineType | ||
import com.smarttoolfactory.cropper.model.RectCropShape | ||
import com.smarttoolfactory.cropper.settings.CropDefaults | ||
import com.smarttoolfactory.cropper.settings.CropOutlineProperty | ||
import dev.olshevski.navigation.reimagined.NavController | ||
import dev.olshevski.navigation.reimagined.pop | ||
import kotlinx.coroutines.launch | ||
import ru.tech.imageresizershrinker.R | ||
import ru.tech.imageresizershrinker.crop_screen.viewModel.CropViewModel | ||
import ru.tech.imageresizershrinker.main_screen.Screen | ||
import ru.tech.imageresizershrinker.main_screen.isExternalStorageWritable | ||
import ru.tech.imageresizershrinker.main_screen.requestPermission | ||
import ru.tech.imageresizershrinker.resize_screen.components.ImageNotPickedWidget | ||
import ru.tech.imageresizershrinker.resize_screen.components.LoadingDialog | ||
import ru.tech.imageresizershrinker.resize_screen.components.ToastHost | ||
import ru.tech.imageresizershrinker.resize_screen.components.rememberToastHostState | ||
import ru.tech.imageresizershrinker.utils.BitmapUtils.decodeBitmapFromUri | ||
import ru.tech.imageresizershrinker.utils.BitmapUtils.shareBitmap | ||
import java.io.File | ||
|
||
@OptIn(ExperimentalMaterial3Api::class) | ||
@Composable | ||
fun CropScreen( | ||
uriState: Uri?, | ||
navController: NavController<Screen>, | ||
onGoBack: () -> Unit, | ||
viewModel: CropViewModel = viewModel() | ||
) { | ||
val context = LocalContext.current as ComponentActivity | ||
val toastHostState = rememberToastHostState() | ||
val scope = rememberCoroutineScope() | ||
val themeState = LocalDynamicThemeState.current | ||
|
||
LaunchedEffect(uriState) { | ||
uriState?.let { | ||
try { | ||
context.decodeBitmapFromUri( | ||
uri = it, | ||
onGetMimeType = viewModel::updateMimeType, | ||
onGetExif = {}, | ||
onGetBitmap = viewModel::updateBitmap, | ||
) | ||
} catch (e: Exception) { | ||
scope.launch { | ||
toastHostState.showToast( | ||
context.getString( | ||
R.string.smth_went_wrong, | ||
e.localizedMessage ?: "" | ||
), | ||
Icons.Rounded.ErrorOutline | ||
) | ||
} | ||
} | ||
} | ||
} | ||
LaunchedEffect(viewModel.bitmap) { | ||
viewModel.bitmap?.let { | ||
themeState.updateColorByImage(it) | ||
} | ||
} | ||
|
||
val pickImageLauncher = | ||
rememberLauncherForActivityResult( | ||
contract = ActivityResultContracts.PickVisualMedia() | ||
) { uri -> | ||
uri?.let { | ||
try { | ||
context.decodeBitmapFromUri( | ||
uri = it, | ||
onGetMimeType = {}, | ||
onGetExif = {}, | ||
onGetBitmap = viewModel::updateBitmap, | ||
) | ||
} catch (e: Exception) { | ||
scope.launch { | ||
toastHostState.showToast( | ||
context.getString( | ||
R.string.smth_went_wrong, | ||
e.localizedMessage ?: "" | ||
), | ||
Icons.Rounded.ErrorOutline | ||
) | ||
} | ||
} | ||
} | ||
} | ||
|
||
val pickImage = { | ||
pickImageLauncher.launch( | ||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) | ||
) | ||
} | ||
|
||
var showSaveLoading by rememberSaveable { mutableStateOf(false) } | ||
val saveBitmap: (Bitmap) -> Unit = { | ||
showSaveLoading = true | ||
viewModel.saveBitmap( | ||
bitmap = it, | ||
isExternalStorageWritable = context.isExternalStorageWritable(), | ||
getFileOutputStream = { name, ext -> | ||
val contentValues = ContentValues().apply { | ||
put(MediaStore.MediaColumns.DISPLAY_NAME, name) | ||
put( | ||
MediaStore.MediaColumns.MIME_TYPE, | ||
"image/$ext" | ||
) | ||
put( | ||
MediaStore.MediaColumns.RELATIVE_PATH, | ||
"DCIM/ResizedImages" | ||
) | ||
} | ||
val imageUri = context.contentResolver.insert( | ||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, | ||
contentValues | ||
) | ||
context.contentResolver.openOutputStream(imageUri!!) | ||
}, | ||
getExternalStorageDir = { | ||
File( | ||
Environment.getExternalStoragePublicDirectory( | ||
Environment.DIRECTORY_DCIM | ||
), "ResizedImages" | ||
) | ||
} | ||
) { success -> | ||
if (!success) context.requestPermission() | ||
else { | ||
scope.launch { | ||
toastHostState.showToast( | ||
context.getString(R.string.saved_to), | ||
Icons.Rounded.Save | ||
) | ||
} | ||
} | ||
showSaveLoading = false | ||
} | ||
} | ||
|
||
var crop by remember { mutableStateOf(false) } | ||
var share by remember { mutableStateOf(false) } | ||
Box(Modifier.fillMaxSize()) { | ||
Column(horizontalAlignment = Alignment.CenterHorizontally) { | ||
TopAppBar( | ||
modifier = Modifier.shadow(6.dp), | ||
title = { | ||
Text(stringResource(R.string.crop)) | ||
}, | ||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( | ||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation( | ||
3.dp | ||
) | ||
), | ||
navigationIcon = { | ||
IconButton( | ||
onClick = { | ||
if (navController.backstack.entries.isNotEmpty()) navController.pop() | ||
onGoBack() | ||
themeState.reset() | ||
} | ||
) { | ||
Icon(Icons.Rounded.ArrowBack, null) | ||
} | ||
}, | ||
actions = { | ||
IconButton( | ||
onClick = { | ||
share = true | ||
crop = true | ||
}, | ||
enabled = viewModel.bitmap != null | ||
) { | ||
Icon(Icons.Outlined.Share, null) | ||
} | ||
} | ||
) | ||
viewModel.bitmap?.let { | ||
val bmp = remember(it) { it.asImageBitmap() } | ||
ImageCropper( | ||
modifier = Modifier.padding(bottom = 120.dp), | ||
imageBitmap = bmp, | ||
contentDescription = null, | ||
cropProperties = CropDefaults.properties( | ||
cropOutlineProperty = CropOutlineProperty( | ||
OutlineType.Rect, | ||
RectCropShape(0, "") | ||
) | ||
), | ||
onCropStart = {}, | ||
crop = crop, | ||
onCropSuccess = { image -> | ||
if (share) { | ||
context.shareBitmap( | ||
bitmap = image.asAndroidBitmap(), | ||
compressFormat = viewModel.mimeType | ||
) | ||
} else { | ||
saveBitmap(image.asAndroidBitmap()) | ||
} | ||
crop = false | ||
share = false | ||
} | ||
) | ||
} ?: Column { | ||
Spacer(Modifier.height(16.dp)) | ||
ImageNotPickedWidget( | ||
onPickImage = pickImage | ||
) | ||
} | ||
} | ||
|
||
Row( | ||
modifier = Modifier | ||
.padding(16.dp) | ||
.navigationBarsPadding() | ||
.align(Alignment.BottomEnd) | ||
) { | ||
FloatingActionButton( | ||
onClick = pickImage, | ||
modifier = Modifier.navigationBarsPadding() | ||
) { | ||
Row( | ||
modifier = Modifier.padding(horizontal = 16.dp), | ||
verticalAlignment = Alignment.CenterVertically | ||
) { | ||
Icon(Icons.Rounded.AddPhotoAlternate, null) | ||
Spacer(Modifier.width(8.dp)) | ||
Text(stringResource(R.string.pick_image_alt)) | ||
} | ||
} | ||
if (viewModel.bitmap != null) { | ||
Spacer(modifier = Modifier.width(16.dp)) | ||
FloatingActionButton( | ||
onClick = { | ||
crop = true | ||
}, | ||
containerColor = MaterialTheme.colorScheme.tertiaryContainer | ||
) { | ||
Icon(Icons.Rounded.Save, null) | ||
} | ||
} | ||
} | ||
} | ||
|
||
if (showSaveLoading) { | ||
LoadingDialog() | ||
} | ||
|
||
ToastHost(hostState = toastHostState) | ||
BackHandler { | ||
if (navController.backstack.entries.isNotEmpty()) navController.pop() | ||
onGoBack() | ||
themeState.reset() | ||
} | ||
} |
85 changes: 85 additions & 0 deletions
85
app/src/main/java/ru/tech/imageresizershrinker/crop_screen/viewModel/CropViewModel.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
package ru.tech.imageresizershrinker.crop_screen.viewModel | ||
|
||
import android.graphics.Bitmap | ||
import android.graphics.BitmapFactory | ||
import android.os.Build | ||
import androidx.compose.runtime.MutableState | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.mutableStateOf | ||
import androidx.lifecycle.ViewModel | ||
import androidx.lifecycle.viewModelScope | ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.launch | ||
import kotlinx.coroutines.withContext | ||
import java.io.* | ||
import java.text.SimpleDateFormat | ||
import java.util.* | ||
|
||
|
||
class CropViewModel : ViewModel() { | ||
|
||
private val _bitmap: MutableState<Bitmap?> = mutableStateOf(null) | ||
val bitmap: Bitmap? by _bitmap | ||
|
||
var mimeType = Bitmap.CompressFormat.PNG | ||
private set | ||
|
||
fun updateBitmap(bitmap: Bitmap?) { | ||
_bitmap.value = bitmap | ||
} | ||
|
||
fun updateMimeType(mime: Int) { | ||
when (mime) { | ||
0 -> mimeType = Bitmap.CompressFormat.JPEG | ||
1 -> mimeType = Bitmap.CompressFormat.WEBP | ||
2 -> mimeType = Bitmap.CompressFormat.PNG | ||
} | ||
} | ||
|
||
fun saveBitmap( | ||
bitmap: Bitmap? = _bitmap.value, | ||
isExternalStorageWritable: Boolean, | ||
getFileOutputStream: (name: String, ext: String) -> OutputStream?, | ||
getExternalStorageDir: () -> File?, | ||
onSuccess: (Boolean) -> Unit | ||
) = viewModelScope.launch { | ||
withContext(Dispatchers.IO) { | ||
bitmap?.let { bitmap -> | ||
if (!isExternalStorageWritable) { | ||
onSuccess(false) | ||
} else { | ||
val ext = | ||
if (mimeType == Bitmap.CompressFormat.WEBP) "webp" else if (mimeType == Bitmap.CompressFormat.PNG) "png" else "jpg" | ||
|
||
val timeStamp: String = | ||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) | ||
val name = "ResizedImage$timeStamp.$ext" | ||
val localBitmap = bitmap | ||
val fos: OutputStream? = | ||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { | ||
getFileOutputStream(name, ext) | ||
} else { | ||
val imagesDir = getExternalStorageDir() | ||
if (imagesDir?.exists() == false) imagesDir.mkdir() | ||
val image = File(imagesDir, name) | ||
FileOutputStream(image) | ||
} | ||
localBitmap.compress(mimeType, 100, fos) | ||
val out = ByteArrayOutputStream() | ||
localBitmap.compress(mimeType, 100, out) | ||
val decoded = | ||
BitmapFactory.decodeStream(ByteArrayInputStream(out.toByteArray())) | ||
|
||
out.flush() | ||
out.close() | ||
fos!!.flush() | ||
fos.close() | ||
|
||
_bitmap.value = decoded | ||
onSuccess(true) | ||
} | ||
} | ||
} | ||
} | ||
|
||
} |
Oops, something went wrong.