From 8bad4c4f74e3f4401fd1b8ca69ee3488bf8bfe37 Mon Sep 17 00:00:00 2001 From: T8RIN Date: Tue, 17 Sep 2024 02:58:29 +0300 Subject: [PATCH] Added Collage Maker by #881 --- .../core/data/saving/AndroidFileController.kt | 8 +- .../resources/src/main/res/values/strings.xml | 4 + .../core/ui/utils/navigation/Screen.kt | 14 +- .../widget/other/DrawLockScreenOrientation.kt | 12 + .../core/ui/widget/utils/ScreenList.kt | 3 + feature/colllage-maker/.gitignore | 1 + feature/colllage-maker/build.gradle.kts | 12 + .../src/main/AndroidManifest.xml | 4 + .../presentation/CollageMakerContent.kt | 544 ++++++++++++++++++ .../viewModel/CollageMakerViewModel.kt | 254 ++++++++ .../feature/draw/presentation/DrawContent.kt | 18 +- .../presentation/NoiseGenerationContent.kt | 83 +-- .../viewModel/NoiseGenerationViewModel.kt | 2 +- feature/root/build.gradle.kts | 1 + .../presentation/components/ScreenSelector.kt | 9 + gradle/libs.versions.toml | 5 +- settings.gradle.kts | 1 + 17 files changed, 911 insertions(+), 64 deletions(-) create mode 100644 feature/colllage-maker/.gitignore create mode 100644 feature/colllage-maker/build.gradle.kts create mode 100644 feature/colllage-maker/src/main/AndroidManifest.xml create mode 100644 feature/colllage-maker/src/main/java/ru/tech/imageresizershrinker/colllage_maker/presentation/CollageMakerContent.kt create mode 100644 feature/colllage-maker/src/main/java/ru/tech/imageresizershrinker/colllage_maker/presentation/viewModel/CollageMakerViewModel.kt diff --git a/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/saving/AndroidFileController.kt b/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/saving/AndroidFileController.kt index 2fcf201a4..f9a5d5f9b 100644 --- a/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/saving/AndroidFileController.kt +++ b/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/saving/AndroidFileController.kt @@ -121,11 +121,8 @@ internal class AndroidFileController @Inject constructor( } val originalUri = saveTarget.originalUri.toUri() - val hasOriginalUri = runCatching { - context.contentResolver.openFileDescriptor(originalUri, "r") - }.isSuccess - if (settingsState.overwriteFiles && hasOriginalUri) { + if (settingsState.overwriteFiles) { runCatching { if (originalUri == Uri.EMPTY) throw IllegalStateException() @@ -258,7 +255,8 @@ internal class AndroidFileController @Inject constructor( return@withContext SaveResult.Success( message = if (savingPath.isNotEmpty()) { - val isFile = documentFile?.isDirectory != true + val isFile = + (documentFile?.isDirectory != true && oneTimeSaveLocationUri != null) if (isFile) { context.getString(R.string.saved_to_custom) } else if (filename.isNotEmpty()) { diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index 78a3d2858..cc1144ea5 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -1406,4 +1406,8 @@ Custom Filename Select location and filename which are will be used to save current image Saved to folder with custom name + Collage Maker + Make various collages from 2..10 images + Collage Type + Pick 2..10 images \ No newline at end of file diff --git a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/utils/navigation/Screen.kt b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/utils/navigation/Screen.kt index dc6afde50..9711f166c 100644 --- a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/utils/navigation/Screen.kt +++ b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/utils/navigation/Screen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.outlined.BrandingWatermark import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.FilterHdr import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.AutoAwesomeMosaic import androidx.compose.material.icons.outlined.AutoFixHigh import androidx.compose.material.icons.outlined.Collections import androidx.compose.material.icons.outlined.ColorLens @@ -118,6 +119,7 @@ sealed class Screen( is ColorTools -> "Color_Tools" is WebpTools -> "WEBP_Tools" is NoiseGeneration -> "Noise_Generation" + is CollageMaker -> "Collage_Maker" } val icon: ImageVector? @@ -159,6 +161,7 @@ sealed class Screen( ColorTools -> Icons.Outlined.ColorLens is WebpTools -> Icons.Rounded.WebpBox NoiseGeneration -> Icons.Outlined.Grain + is CollageMaker -> Icons.Outlined.AutoAwesomeMosaic } data object Settings : Screen( @@ -707,6 +710,14 @@ sealed class Screen( subtitle = R.string.noise_generation_sub ) + data class CollageMaker( + val uris: List? = null + ) : Screen( + id = 33, + title = R.string.collage_maker, + subtitle = R.string.collage_maker_sub + ) + companion object { val typedEntries by lazy { listOf( @@ -726,6 +737,7 @@ sealed class Screen( Filter(), Draw(), EraseBackground(), + CollageMaker(), ImageStitching(), ImageStacking(), ImageSplitting(), @@ -773,6 +785,6 @@ sealed class Screen( typedEntries.flatMap { it.first }.sortedBy { it.id } } - const val FEATURES_COUNT = 52 + const val FEATURES_COUNT = 53 } } \ No newline at end of file diff --git a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/other/DrawLockScreenOrientation.kt b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/other/DrawLockScreenOrientation.kt index 6bcf33cd3..ddc346b82 100644 --- a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/other/DrawLockScreenOrientation.kt +++ b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/other/DrawLockScreenOrientation.kt @@ -40,6 +40,18 @@ fun DrawLockScreenOrientation() { } } +@Composable +fun LockScreenOrientation() { + val activity = LocalContext.current as Activity + DisposableEffect(activity) { + val originalOrientation = activity.requestedOrientation + activity.lockOrientation() + onDispose { + activity.requestedOrientation = originalOrientation + } + } +} + private fun Activity.lockOrientation() { val display = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { display diff --git a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/utils/ScreenList.kt b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/utils/ScreenList.kt index dd45332d6..d2dfac2d6 100644 --- a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/utils/ScreenList.kt +++ b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/utils/ScreenList.kt @@ -174,6 +174,9 @@ internal fun List.screenList( add(Screen.ImageStitching(uris)) add(Screen.PdfTools(Screen.PdfTools.Type.ImagesToPdf(uris))) if (uris.size == 2) add(Screen.Compare(uris)) + if (uris.size in 2..10) { + add(Screen.CollageMaker(uris)) + } add(Screen.GradientMaker(uris)) add(Screen.Watermarking(uris)) add( diff --git a/feature/colllage-maker/.gitignore b/feature/colllage-maker/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/colllage-maker/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/colllage-maker/build.gradle.kts b/feature/colllage-maker/build.gradle.kts new file mode 100644 index 000000000..e2748edec --- /dev/null +++ b/feature/colllage-maker/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.image.toolbox.library) + alias(libs.plugins.image.toolbox.feature) + alias(libs.plugins.image.toolbox.hilt) + alias(libs.plugins.image.toolbox.compose) +} + +android.namespace = "ru.tech.imageresizershrinker.feature.collage_maker" + +dependencies { + implementation(libs.toolbox.collages) +} \ No newline at end of file diff --git a/feature/colllage-maker/src/main/AndroidManifest.xml b/feature/colllage-maker/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/feature/colllage-maker/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/colllage-maker/src/main/java/ru/tech/imageresizershrinker/colllage_maker/presentation/CollageMakerContent.kt b/feature/colllage-maker/src/main/java/ru/tech/imageresizershrinker/colllage_maker/presentation/CollageMakerContent.kt new file mode 100644 index 000000000..9d2bada09 --- /dev/null +++ b/feature/colllage-maker/src/main/java/ru/tech/imageresizershrinker/colllage_maker/presentation/CollageMakerContent.kt @@ -0,0 +1,544 @@ +/* + * ImageToolbox is an image editor for android + * Copyright (c) 2024 T8RIN (Malik Mukhametzyanov) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * You should have received a copy of the Apache License + * along with this program. If not, see . + */ + +package ru.tech.imageresizershrinker.colllage_maker.presentation + +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.outlined.AutoAwesomeMosaic +import androidx.compose.material.icons.rounded.FormatLineSpacing +import androidx.compose.material.icons.rounded.RoundedCorner +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.t8rin.collages.Collage +import com.t8rin.collages.CollageTypeSelection +import dev.olshevski.navigation.reimagined.hilt.hiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import ru.tech.imageresizershrinker.colllage_maker.presentation.viewModel.CollageMakerViewModel +import ru.tech.imageresizershrinker.core.resources.R +import ru.tech.imageresizershrinker.core.settings.presentation.provider.LocalSettingsState +import ru.tech.imageresizershrinker.core.ui.utils.confetti.LocalConfettiHostState +import ru.tech.imageresizershrinker.core.ui.utils.helper.Picker +import ru.tech.imageresizershrinker.core.ui.utils.helper.asClip +import ru.tech.imageresizershrinker.core.ui.utils.helper.isPortraitOrientationAsState +import ru.tech.imageresizershrinker.core.ui.utils.helper.localImagePickerMode +import ru.tech.imageresizershrinker.core.ui.utils.helper.parseSaveResult +import ru.tech.imageresizershrinker.core.ui.utils.helper.rememberImagePicker +import ru.tech.imageresizershrinker.core.ui.utils.navigation.Screen +import ru.tech.imageresizershrinker.core.ui.utils.provider.ProvideContainerDefaults +import ru.tech.imageresizershrinker.core.ui.widget.buttons.BottomButtonsBlock +import ru.tech.imageresizershrinker.core.ui.widget.buttons.EnhancedIconButton +import ru.tech.imageresizershrinker.core.ui.widget.buttons.ShareButton +import ru.tech.imageresizershrinker.core.ui.widget.controls.EnhancedSliderItem +import ru.tech.imageresizershrinker.core.ui.widget.controls.selection.BackgroundColorSelector +import ru.tech.imageresizershrinker.core.ui.widget.controls.selection.ImageFormatSelector +import ru.tech.imageresizershrinker.core.ui.widget.controls.selection.QualitySelector +import ru.tech.imageresizershrinker.core.ui.widget.dialogs.ExitWithoutSavingDialog +import ru.tech.imageresizershrinker.core.ui.widget.dialogs.OneTimeSaveLocationSelectionDialog +import ru.tech.imageresizershrinker.core.ui.widget.image.AutoFilePicker +import ru.tech.imageresizershrinker.core.ui.widget.image.ImageNotPickedWidget +import ru.tech.imageresizershrinker.core.ui.widget.modifier.container +import ru.tech.imageresizershrinker.core.ui.widget.modifier.fadingEdges +import ru.tech.imageresizershrinker.core.ui.widget.modifier.transparencyChecker +import ru.tech.imageresizershrinker.core.ui.widget.other.EnhancedTopAppBar +import ru.tech.imageresizershrinker.core.ui.widget.other.EnhancedTopAppBarType +import ru.tech.imageresizershrinker.core.ui.widget.other.LoadingDialog +import ru.tech.imageresizershrinker.core.ui.widget.other.LocalToastHostState +import ru.tech.imageresizershrinker.core.ui.widget.other.LockScreenOrientation +import ru.tech.imageresizershrinker.core.ui.widget.sheets.ProcessImagesPreferenceSheet +import ru.tech.imageresizershrinker.core.ui.widget.sheets.SimpleSheetDefaults +import ru.tech.imageresizershrinker.core.ui.widget.text.TopAppBarTitle + +@Composable +fun CollageMakerContent( + uriState: List?, + onGoBack: () -> Unit, + onNavigate: (Screen) -> Unit, + viewModel: CollageMakerViewModel = hiltViewModel() +) { + LockScreenOrientation() + val context = LocalContext.current as ComponentActivity + val toastHostState = LocalToastHostState.current + + val scope = rememberCoroutineScope() + val confettiHostState = LocalConfettiHostState.current + val showConfetti: () -> Unit = { + scope.launch { + confettiHostState.showConfetti() + } + } + + LaunchedEffect(uriState) { + uriState?.takeIf { it.isNotEmpty() }?.let { + if (it.size in 2..10) { + viewModel.updateUris(it) + } else { + scope.launch { + toastHostState.showToast( + message = context.getString(R.string.pick_images_collage), + icon = Icons.Outlined.AutoAwesomeMosaic + ) + } + } + } + } + + val pickImageLauncher = rememberImagePicker( + mode = localImagePickerMode(Picker.Multiple) + ) { list -> + list.takeIf { it.isNotEmpty() }?.let { + if (list.size in 2..10) { + viewModel.updateUris(list) + } else { + scope.launch { + toastHostState.showToast( + message = context.getString(R.string.pick_images_collage), + icon = Icons.Outlined.AutoAwesomeMosaic + ) + } + } + } + } + + val pickImage = pickImageLauncher::pickImage + + AutoFilePicker( + onAutoPick = pickImage, + isPickedAlready = !uriState.isNullOrEmpty() + ) + + val saveBitmaps: (oneTimeSaveLocationUri: String?) -> Unit = { + viewModel.saveBitmap(it) { saveResult -> + context.parseSaveResult( + scope = scope, + saveResult = saveResult, + toastHostState = toastHostState, + onSuccess = showConfetti + ) + } + } + + val isPortrait by isPortraitOrientationAsState() + + var showExitDialog by rememberSaveable { mutableStateOf(false) } + + val onBack = { + if (viewModel.haveChanges) showExitDialog = true + else onGoBack() + } + + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + confirmValueChange = { + when (it) { + SheetValue.Hidden -> false + else -> true + } + } + ) + ) + + val focus = LocalFocusManager.current + + LaunchedEffect(scaffoldState.bottomSheetState.currentValue) { + if (scaffoldState.bottomSheetState.currentValue != SheetValue.Expanded) { + focus.clearFocus() + } + } + + val collagePreview: @Composable () -> Unit = { + Box( + modifier = Modifier.container( + shape = RoundedCornerShape(4.dp), + resultPadding = 0.dp + ) + ) { + Collage( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + .clip(RoundedCornerShape(4.dp)) + .transparencyChecker(), + images = viewModel.uris ?: emptyList(), + collageType = viewModel.collageType, + collageCreationTrigger = viewModel.collageCreationTrigger, + onCollageCreated = viewModel::updateCollageBitmap, + backgroundColor = viewModel.backgroundColor, + spacing = viewModel.spacing, + cornerRadius = viewModel.cornerRadius + ) + } + } + + val controls: @Composable () -> Unit = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.container( + resultPadding = 0.dp, + shape = RoundedCornerShape(24.dp) + ) + ) { + Text( + fontWeight = FontWeight.Medium, + text = stringResource(R.string.collage_type), + modifier = Modifier.padding(top = 16.dp), + fontSize = 18.sp + ) + val state = rememberLazyListState() + CollageTypeSelection( + state = state, + imagesCount = viewModel.uris?.size ?: 0, + value = viewModel.collageType, + onValueChange = viewModel::setCollageType, + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .fadingEdges(state), + contentPadding = PaddingValues(10.dp), + shape = RoundedCornerShape(12.dp), + itemModifierFactory = { isSelected -> + Modifier + .container( + resultPadding = 0.dp, + color = animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else MaterialTheme.colorScheme.surfaceContainerLowest, + ).value, + shape = RoundedCornerShape(12.dp) + ) + .padding(8.dp) + .clip(RoundedCornerShape(2.dp)) + } + ) + } + Spacer(Modifier.height(8.dp)) + BackgroundColorSelector( + modifier = Modifier + .fillMaxWidth() + .container( + shape = RoundedCornerShape(24.dp), + resultPadding = 0.dp + ), + value = viewModel.backgroundColor, + onValueChange = viewModel::setBackgroundColor + ) + Spacer(Modifier.height(8.dp)) + EnhancedSliderItem( + modifier = Modifier.fillMaxWidth(), + value = viewModel.spacing, + title = stringResource(R.string.spacing), + valueRange = 0f..50f, + onValueChange = viewModel::setSpacing, + sliderModifier = Modifier + .padding( + top = 14.dp, + start = 12.dp, + end = 12.dp, + bottom = 10.dp + ), + icon = Icons.Rounded.FormatLineSpacing, + shape = RoundedCornerShape(24.dp) + ) + Spacer(Modifier.height(8.dp)) + EnhancedSliderItem( + modifier = Modifier.fillMaxWidth(), + value = viewModel.cornerRadius, + title = stringResource(R.string.corners), + valueRange = 0f..50f, + onValueChange = viewModel::setCornerRadius, + sliderModifier = Modifier + .padding( + top = 14.dp, + start = 12.dp, + end = 12.dp, + bottom = 10.dp + ), + icon = Icons.Rounded.RoundedCorner, + shape = RoundedCornerShape(24.dp) + ) + Spacer(Modifier.height(8.dp)) + QualitySelector( + imageFormat = viewModel.imageFormat, + quality = viewModel.quality, + onQualityChange = viewModel::setQuality + ) + Spacer(Modifier.height(8.dp)) + ImageFormatSelector( + value = viewModel.imageFormat, + onValueChange = viewModel::setImageFormat, + forceEnabled = true + ) + } + + val actions: @Composable RowScope.() -> Unit = { + var editSheetData by remember { + mutableStateOf(listOf()) + } + EnhancedIconButton( + containerColor = Color.Transparent, + contentColor = LocalContentColor.current, + enableAutoShadowAndBorder = false, + onClick = { + scope.launch { + if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) { + scaffoldState.bottomSheetState.partialExpand() + } else { + scaffoldState.bottomSheetState.expand() + } + } + }, + ) { + Icon( + imageVector = Icons.Rounded.Tune, + contentDescription = stringResource(R.string.properties) + ) + } + ShareButton( + onShare = { + viewModel.performSharing(showConfetti) + }, + onCopy = { manager -> + viewModel.cacheImage { uri -> + manager.setClip(uri.asClip(context)) + showConfetti() + } + }, + onEdit = { + viewModel.cacheImage { + editSheetData = listOf(it) + } + } + ) + ProcessImagesPreferenceSheet( + uris = editSheetData, + visible = editSheetData.isNotEmpty(), + onDismiss = { + if (!it) { + editSheetData = emptyList() + } + }, + onNavigate = { screen -> + scope.launch { + editSheetData = emptyList() + delay(200) + onNavigate(screen) + } + } + ) + } + + val buttons: @Composable () -> Unit = { + var showFolderSelectionDialog by rememberSaveable { + mutableStateOf(false) + } + BottomButtonsBlock( + targetState = (viewModel.uris.isNullOrEmpty()) to isPortrait, + onSecondaryButtonClick = pickImage, + onPrimaryButtonClick = { + saveBitmaps(null) + }, + onPrimaryButtonLongClick = { + showFolderSelectionDialog = true + }, + actions = { + if (isPortrait) actions() + } + ) + if (showFolderSelectionDialog) { + OneTimeSaveLocationSelectionDialog( + onDismiss = { showFolderSelectionDialog = false }, + onSaveRequest = saveBitmaps, + formatForFilenameSelection = viewModel.getFormatForFilenameSelection() + ) + } + } + + val noDataControls: @Composable () -> Unit = { + if (!viewModel.isImageLoading) { + ImageNotPickedWidget(onPickImage = pickImage) + } + } + + val topAppBar: @Composable () -> Unit = { + EnhancedTopAppBar( + title = { + TopAppBarTitle( + title = stringResource(R.string.collage_maker), + input = viewModel.uris, + isLoading = viewModel.isImageLoading, + size = null + ) + }, + navigationIcon = { + EnhancedIconButton( + containerColor = Color.Transparent, + contentColor = LocalContentColor.current, + enableAutoShadowAndBorder = false, + onClick = onBack + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.exit) + ) + } + }, + type = if (viewModel.uris.isNullOrEmpty()) EnhancedTopAppBarType.Large + else EnhancedTopAppBarType.Normal + ) + } + + AnimatedContent(viewModel.uris.isNullOrEmpty()) { noData -> + if (noData) { + Box( + modifier = Modifier.fillMaxSize() + ) { + Column { + topAppBar() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + noDataControls() + } + } + val settingsState = LocalSettingsState.current + if (isPortrait) { + Box( + modifier = Modifier.align(settingsState.fabAlignment) + ) { + buttons() + } + } + } + } else { + BottomSheetScaffold( + sheetContent = { + Column( + Modifier + .fillMaxHeight(0.6f) + .pointerInput(Unit) { + detectTapGestures { focus.clearFocus() } + } + ) { + buttons() + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .navigationBarsPadding() + ) { + ProvideContainerDefaults( + color = SimpleSheetDefaults.contentContainerColor + ) { + controls() + } + } + } + }, + sheetPeekHeight = 80.dp + WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding(), + sheetDragHandle = null, + sheetShape = RectangleShape, + scaffoldState = scaffoldState + ) { + Column(modifier = Modifier.padding(it)) { + topAppBar() + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + collagePreview() + } + } + } + } + } + + BackHandler(onBack = onBack) + + + + ExitWithoutSavingDialog( + onExit = onGoBack, + onDismiss = { showExitDialog = false }, + visible = showExitDialog + ) + + if (viewModel.isSaving || viewModel.isImageLoading) { + LoadingDialog( + onCancelLoading = viewModel::cancelSaving + ) + } +} \ No newline at end of file diff --git a/feature/colllage-maker/src/main/java/ru/tech/imageresizershrinker/colllage_maker/presentation/viewModel/CollageMakerViewModel.kt b/feature/colllage-maker/src/main/java/ru/tech/imageresizershrinker/colllage_maker/presentation/viewModel/CollageMakerViewModel.kt new file mode 100644 index 000000000..85b675f09 --- /dev/null +++ b/feature/colllage-maker/src/main/java/ru/tech/imageresizershrinker/colllage_maker/presentation/viewModel/CollageMakerViewModel.kt @@ -0,0 +1,254 @@ +/* + * ImageToolbox is an image editor for android + * Copyright (c) 2024 T8RIN (Malik Mukhametzyanov) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * You should have received a copy of the Apache License + * along with this program. If not, see . + */ + +package ru.tech.imageresizershrinker.colllage_maker.presentation.viewModel + +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface +import androidx.lifecycle.viewModelScope +import com.t8rin.collages.CollageType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import ru.tech.imageresizershrinker.core.domain.dispatchers.DispatchersHolder +import ru.tech.imageresizershrinker.core.domain.image.ImageCompressor +import ru.tech.imageresizershrinker.core.domain.image.ImageGetter +import ru.tech.imageresizershrinker.core.domain.image.ShareProvider +import ru.tech.imageresizershrinker.core.domain.image.model.ImageFormat +import ru.tech.imageresizershrinker.core.domain.image.model.ImageInfo +import ru.tech.imageresizershrinker.core.domain.image.model.Quality +import ru.tech.imageresizershrinker.core.domain.model.IntegerSize +import ru.tech.imageresizershrinker.core.domain.saving.FileController +import ru.tech.imageresizershrinker.core.domain.saving.model.ImageSaveTarget +import ru.tech.imageresizershrinker.core.domain.saving.model.SaveResult +import ru.tech.imageresizershrinker.core.domain.utils.smartJob +import ru.tech.imageresizershrinker.core.ui.utils.BaseViewModel +import ru.tech.imageresizershrinker.core.ui.utils.state.update +import javax.inject.Inject + +@HiltViewModel +class CollageMakerViewModel @Inject constructor( + private val fileController: FileController, + private val imageCompressor: ImageCompressor, + private val shareProvider: ShareProvider, + private val imageGetter: ImageGetter, + dispatchersHolder: DispatchersHolder +) : BaseViewModel(dispatchersHolder) { + + private val _spacing = mutableFloatStateOf(0f) + val spacing: Float by _spacing + + private val _cornerRadius = mutableFloatStateOf(0f) + val cornerRadius: Float by _cornerRadius + + private val _backgroundColor = mutableStateOf(Color.Black) + val backgroundColor: Color by _backgroundColor + + private val _collageCreationTrigger = mutableStateOf(false) + val collageCreationTrigger by _collageCreationTrigger + + private val _collageType: MutableState = mutableStateOf(CollageType.Empty) + val collageType by _collageType + + private val _collageBitmap = mutableStateOf(null) + private val collageBitmap by _collageBitmap + + private val _uris = mutableStateOf?>(null) + val uris by _uris + + private val _imageFormat: MutableState = mutableStateOf(ImageFormat.Default) + val imageFormat: ImageFormat by _imageFormat + + private val _quality: MutableState = mutableStateOf(Quality.Base()) + val quality: Quality by _quality + + private val _isSaving: MutableState = mutableStateOf(false) + val isSaving: Boolean by _isSaving + + private var requestedOperation: () -> Unit = {} + + fun setCollageType(collageType: CollageType) { + _collageType.update { collageType } + registerChanges() + } + + fun updateCollageBitmap(bitmap: Bitmap) { + _collageCreationTrigger.update { false } + _collageBitmap.update { bitmap } + requestedOperation() + } + + fun updateUris(uris: List?) { + viewModelScope.launch { + _isImageLoading.update { true } + _uris.update { + uris?.mapNotNull { + val image = + imageGetter.getImage(it, IntegerSize(2000, 2000)) ?: return@mapNotNull null + + shareProvider.cacheImage( + image = image, + imageInfo = ImageInfo( + width = image.width, + height = image.height, + quality = Quality.Base(100), + imageFormat = ImageFormat.Png.Lossless + ) + )?.toUri() + } + } + _isImageLoading.update { false } + } + } + + fun setQuality(quality: Quality) { + _quality.update { quality } + registerChanges() + } + + fun setImageFormat(imageFormat: ImageFormat) { + _imageFormat.update { imageFormat } + registerChanges() + } + + private var savingJob: Job? by smartJob { + _isSaving.update { false } + } + + fun saveBitmap( + oneTimeSaveLocationUri: String?, + onComplete: (SaveResult) -> Unit + ) { + _isSaving.update { true } + _collageCreationTrigger.update { true } + requestedOperation = { + savingJob = viewModelScope.launch(defaultDispatcher) { + collageBitmap?.let { image -> + _isSaving.update { true } + val imageInfo = ImageInfo( + width = image.width, + height = image.height, + quality = quality, + imageFormat = imageFormat + ) + val result = fileController.save( + saveTarget = ImageSaveTarget( + imageInfo = imageInfo, + originalUri = "", + sequenceNumber = null, + data = imageCompressor.compress( + image = image, + imageFormat = imageFormat, + quality = quality + ) + ), + keepOriginalMetadata = false, + oneTimeSaveLocationUri = oneTimeSaveLocationUri + ) + + onComplete(result.onSuccess(::registerSave)) + _isSaving.update { false } + } + } + } + } + + fun performSharing( + onComplete: () -> Unit + ) { + _isSaving.update { true } + _collageCreationTrigger.update { true } + requestedOperation = { + collageBitmap?.let { image -> + savingJob = viewModelScope.launch { + _isSaving.update { true } + shareProvider.cacheImage( + image = image, + imageInfo = ImageInfo( + width = image.width, + height = image.height, + quality = quality, + imageFormat = imageFormat + ) + )?.let { uri -> + shareProvider.shareUri( + uri = uri, + onComplete = onComplete + ) + } + _isSaving.update { false } + } + } + } + } + + fun cacheImage( + onComplete: (Uri) -> Unit + ) { + _isSaving.update { true } + _collageCreationTrigger.update { true } + requestedOperation = { + collageBitmap?.let { image -> + savingJob = viewModelScope.launch { + _isSaving.update { true } + shareProvider.cacheImage( + image = image, + imageInfo = ImageInfo( + width = image.width, + height = image.height, + quality = quality, + imageFormat = imageFormat + ) + )?.let { uri -> + onComplete(uri.toUri()) + } + _isSaving.update { false } + } + } + } + } + + fun cancelSaving() { + savingJob?.cancel() + savingJob = null + _isSaving.update { false } + } + + fun setBackgroundColor(color: Color) { + _backgroundColor.update { color } + registerChanges() + } + + fun setSpacing(value: Float) { + _spacing.update { value } + registerChanges() + } + + fun setCornerRadius(value: Float) { + _cornerRadius.update { value } + registerChanges() + } + + fun getFormatForFilenameSelection(): ImageFormat = imageFormat + +} \ No newline at end of file diff --git a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/DrawContent.kt b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/DrawContent.kt index c6d0df2b4..84b47b9e2 100644 --- a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/DrawContent.kt +++ b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/DrawContent.kt @@ -21,7 +21,6 @@ package ru.tech.imageresizershrinker.feature.draw.presentation import android.annotation.SuppressLint -import android.content.res.Configuration import android.graphics.Bitmap import android.net.Uri import androidx.activity.ComponentActivity @@ -87,7 +86,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -117,9 +115,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LocalLifecycleOwner import com.t8rin.dynamic.theme.LocalDynamicThemeState -import com.t8rin.dynamic.theme.observeAsState import com.t8rin.dynamic.theme.rememberAppColorTuple import dev.olshevski.navigation.reimagined.hilt.hiltViewModel import kotlinx.coroutines.delay @@ -136,11 +132,11 @@ import ru.tech.imageresizershrinker.core.ui.utils.confetti.LocalConfettiHostStat import ru.tech.imageresizershrinker.core.ui.utils.helper.ImageUtils.restrict import ru.tech.imageresizershrinker.core.ui.utils.helper.Picker import ru.tech.imageresizershrinker.core.ui.utils.helper.asClip +import ru.tech.imageresizershrinker.core.ui.utils.helper.isPortraitOrientationAsState import ru.tech.imageresizershrinker.core.ui.utils.helper.localImagePickerMode import ru.tech.imageresizershrinker.core.ui.utils.helper.parseSaveResult import ru.tech.imageresizershrinker.core.ui.utils.helper.rememberImagePicker import ru.tech.imageresizershrinker.core.ui.utils.navigation.Screen -import ru.tech.imageresizershrinker.core.ui.utils.provider.LocalWindowSizeClass import ru.tech.imageresizershrinker.core.ui.utils.provider.ProvideContainerDefaults import ru.tech.imageresizershrinker.core.ui.widget.buttons.EnhancedButton import ru.tech.imageresizershrinker.core.ui.widget.buttons.EnhancedFloatingActionButton @@ -273,17 +269,7 @@ fun DrawContent( } val configuration = LocalConfiguration.current - val sizeClass = LocalWindowSizeClass.current.widthSizeClass - val portrait = - remember( - LocalLifecycleOwner.current.lifecycle.observeAsState().value, - sizeClass, - configuration - ) { - derivedStateOf { - configuration.orientation != Configuration.ORIENTATION_LANDSCAPE || sizeClass == WindowWidthSizeClass.Compact - } - }.value + val portrait by isPortraitOrientationAsState() var showPickColorSheet by rememberSaveable { mutableStateOf(false) } diff --git a/feature/noise-generation/src/main/java/ru/tech/imageresizershrinker/noise_generation/presentation/NoiseGenerationContent.kt b/feature/noise-generation/src/main/java/ru/tech/imageresizershrinker/noise_generation/presentation/NoiseGenerationContent.kt index f79bc7814..138662163 100644 --- a/feature/noise-generation/src/main/java/ru/tech/imageresizershrinker/noise_generation/presentation/NoiseGenerationContent.kt +++ b/feature/noise-generation/src/main/java/ru/tech/imageresizershrinker/noise_generation/presentation/NoiseGenerationContent.kt @@ -100,6 +100,44 @@ fun NoiseGenerationContent( } } + val shareButton: @Composable () -> Unit = { + var editSheetData by remember { + mutableStateOf(listOf()) + } + ShareButton( + onShare = { + viewModel.shareNoise(showConfetti) + }, + onCopy = { manager -> + viewModel.cacheCurrentNoise { uri -> + manager.setClip(uri.asClip(context)) + showConfetti() + } + }, + onEdit = { + viewModel.cacheCurrentNoise { + editSheetData = listOf(it) + } + } + ) + ProcessImagesPreferenceSheet( + uris = editSheetData, + visible = editSheetData.isNotEmpty(), + onDismiss = { + if (!it) { + editSheetData = emptyList() + } + }, + onNavigate = { screen -> + scope.launch { + editSheetData = emptyList() + delay(200) + onNavigate(screen) + } + } + ) + } + AdaptiveLayoutScreen( title = { Text( @@ -109,43 +147,7 @@ fun NoiseGenerationContent( ) }, onGoBack = onGoBack, - actions = { - var editSheetData by remember { - mutableStateOf(listOf()) - } - ShareButton( - onShare = { - viewModel.shareNoise(showConfetti) - }, - onCopy = { manager -> - viewModel.cacheCurrentNoise { uri -> - manager.setClip(uri.asClip(context)) - showConfetti() - } - }, - onEdit = { - viewModel.cacheCurrentNoise { - editSheetData = listOf(it) - } - } - ) - ProcessImagesPreferenceSheet( - uris = editSheetData, - visible = editSheetData.isNotEmpty(), - onDismiss = { - if (!it) { - editSheetData = emptyList() - } - }, - onNavigate = { screen -> - scope.launch { - editSheetData = emptyList() - delay(200) - onNavigate(screen) - } - } - ) - }, + actions = {}, topAppBarPersistentActions = { TopAppBarEmoji() }, @@ -183,7 +185,8 @@ fun NoiseGenerationContent( Spacer(Modifier.height(4.dp)) ImageFormatSelector( value = viewModel.imageFormat, - onValueChange = viewModel::setImageFormat + onValueChange = viewModel::setImageFormat, + forceEnabled = true ) QualitySelector( quality = viewModel.quality, @@ -206,7 +209,9 @@ fun NoiseGenerationContent( onPrimaryButtonLongClick = { showFolderSelectionDialog = true }, - actions = it + actions = { + shareButton() + } ) if (showFolderSelectionDialog) { OneTimeSaveLocationSelectionDialog( diff --git a/feature/noise-generation/src/main/java/ru/tech/imageresizershrinker/noise_generation/presentation/viewModel/NoiseGenerationViewModel.kt b/feature/noise-generation/src/main/java/ru/tech/imageresizershrinker/noise_generation/presentation/viewModel/NoiseGenerationViewModel.kt index 3b9510340..5cea30cb9 100644 --- a/feature/noise-generation/src/main/java/ru/tech/imageresizershrinker/noise_generation/presentation/viewModel/NoiseGenerationViewModel.kt +++ b/feature/noise-generation/src/main/java/ru/tech/imageresizershrinker/noise_generation/presentation/viewModel/NoiseGenerationViewModel.kt @@ -106,7 +106,7 @@ class NoiseGenerationViewModel @Inject constructor( saveTarget = ImageSaveTarget( imageInfo = imageInfo, metadata = null, - originalUri = "Noise", + originalUri = "", sequenceNumber = null, data = imageCompressor.compress( image = bitmap, diff --git a/feature/root/build.gradle.kts b/feature/root/build.gradle.kts index 4c6fa137c..910ef7e08 100644 --- a/feature/root/build.gradle.kts +++ b/feature/root/build.gradle.kts @@ -61,4 +61,5 @@ dependencies { implementation(projects.feature.colorTools) implementation(projects.feature.webpTools) implementation(projects.feature.noiseGeneration) + implementation(projects.feature.colllageMaker) } \ No newline at end of file diff --git a/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/ScreenSelector.kt b/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/ScreenSelector.kt index de4c05096..207f56a67 100644 --- a/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/ScreenSelector.kt +++ b/feature/root/src/main/java/ru/tech/imageresizershrinker/feature/root/presentation/components/ScreenSelector.kt @@ -29,6 +29,7 @@ import dev.olshevski.navigation.reimagined.navigate import dev.olshevski.navigation.reimagined.popUpTo import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import ru.tech.imageresizershrinker.colllage_maker.presentation.CollageMakerContent import ru.tech.imageresizershrinker.color_tools.presentation.ColorToolsContent import ru.tech.imageresizershrinker.core.domain.utils.Lambda import ru.tech.imageresizershrinker.core.settings.presentation.provider.LocalSettingsState @@ -408,6 +409,14 @@ internal fun ScreenSelector( onNavigate = onNavigate ) } + + is Screen.CollageMaker -> { + CollageMakerContent( + uriState = screen.uris, + onGoBack = onGoBack, + onNavigate = onNavigate + ) + } } } ScreenBasedMaxBrightnessEnforcement(navController.currentDestination()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d873c921..71d92eabb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,13 +3,13 @@ androidMinSdk = "21" androidTargetSdk = "34" androidCompileSdk = "34" -versionName = "3.0.1-alpha01" +versionName = "3.1.0-alpha01" versionCode = "153" jvmTarget = "17" compose-compiler = "1.5.15" -imageToolboxLibs = "2.5.5" +imageToolboxLibs = "2.6.0" trickle = "1.1.2" avifCoder = "1.8.0" @@ -109,6 +109,7 @@ toolbox-awebp = { module = "com.github.T8RIN.ImageToolboxLibs:awebp", version.re toolbox-psd = { module = "com.github.T8RIN.ImageToolboxLibs:psd", version.ref = "imageToolboxLibs" } toolbox-djvuCoder = { module = "com.github.T8RIN.ImageToolboxLibs:djvu-coder", version.ref = "imageToolboxLibs" } toolbox-fastNoise = { module = "com.github.T8RIN.ImageToolboxLibs:fast-noise", version.ref = "imageToolboxLibs" } +toolbox-collages = { module = "com.github.T8RIN.ImageToolboxLibs:collages", version.ref = "imageToolboxLibs" } aire = { module = "com.github.awxkee:aire", version.ref = "aire" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 181d17132..e34961b4d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -101,6 +101,7 @@ include(":feature:image-splitting") include(":feature:color-tools") include(":feature:webp-tools") include(":feature:noise-generation") +include(":feature:colllage-maker") include(":feature:root")